diff --git a/src/release-sync.ts b/src/release-sync.ts index b176040..8fdd32d 100644 --- a/src/release-sync.ts +++ b/src/release-sync.ts @@ -36,7 +36,8 @@ export interface ReleaseArtifactInput { compatibleSkus: string[]; } -export const DEFAULT_ROLLOUT_PERCENTAGE = 10; +/** A new release is registered but served to no device until an operator raises it. */ +export const DEFAULT_ROLLOUT_PERCENTAGE = 0; export type ReleaseOutcome = | "created" diff --git a/src/releases.ts b/src/releases.ts index 30310fd..bc10035 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -1,4 +1,5 @@ import { Request, Response } from "express"; +import type { Prisma } from "@prisma/client"; import { prisma } from "./db"; import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "./errors"; import type { ReleaseSyncRunner } from "./release-sync"; @@ -428,8 +429,10 @@ function dbReleaseToMetadata(release: DbRelease, sku: string): ReleaseMetadata { } /** - * Newest fully rolled out release for the prefix, or null when no release - * has reached 100% yet (a prefix whose first release is still staged). + * Newest fully rolled out release that ships a binary for the SKU, or null + * when there is none: the prefix's first release is still staged, or the SKU + * is new and its first build has not reached 100% yet. The caller decides + * whether the device is in the staged release's bucket before it needs this. */ async function getDefaultRelease(prefix: string, sku: string): Promise { const rolledOutReleases = await prisma.release.findMany({ @@ -447,9 +450,7 @@ async function getDefaultRelease(prefix: string, sku: string): Promise r.artifacts.length > 0); if (compatibleReleases.length === 0) { - throw new NotFoundError( - `No default ${prefix} release available for SKU "${sku}"`, - ); + return null; } const latestVersion = semver.maxSatisfying( @@ -468,17 +469,22 @@ async function getDefaultRelease(prefix: string, sku: string): Promise { - return getReleaseByRange(prefix, sku, "*"); + return getReleaseByRange(prefix, sku, "*", { rolloutPercentage: { gt: 0 } }); } async function getReleaseByRange( prefix: string, sku: string, range: string, + where: Prisma.ReleaseWhereInput = {}, ): Promise { const releases = await prisma.release.findMany({ - where: { type: prefix }, + where: { type: prefix, ...where }, select: compatibleReleaseSelect(sku), }); diff --git a/test/releases.test.ts b/test/releases.test.ts index 9d672bc..7d18033 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -392,6 +392,21 @@ describe("Retrieve handler", () => { }); }); + it("keeps an in-progress rollout when a newer release is registered at 0%", async () => { + // early-adopter hashes to bucket 8, late-adopter to bucket 95. + await createDbReleasePair("2.0.0", 100); + await createDbReleasePair("2.1.0", 50); + await createDbReleasePair("2.2.0", 0); + + const inBucket = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "early-adopter" }), inBucket); + expect(jsonBody(inBucket)).toMatchObject({ appVersion: "2.1.0", systemVersion: "2.1.0" }); + + const outOfBucket = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "late-adopter" }), outOfBucket); + expect(jsonBody(outOfBucket)).toMatchObject({ appVersion: "2.0.0", systemVersion: "2.0.0" }); + }); + it("applies app and system rollout independently", async () => { await createDbReleasePair("2.4.0", 100); await createDbRelease("app", "2.5.0", 100); @@ -608,6 +623,21 @@ describe("Retrieve handler", () => { Retrieve(createMockRequest({ deviceId: "late-adopter" }), createMockResponse()), ).rejects.toThrow(/No (app|system) release is rolled out yet for SKU "jetkvm-v2"/); }); + + it("serves the first staged release for a new SKU when no 100% release ships it", async () => { + // The seeded 100% releases carry jetkvm-v2 only. The first SDMMC build + // is staged at 50%: early-adopter (bucket 8) is in, late-adopter (95) out. + await createDbRelease("app", "1.3.0", 50, [releaseArtifact("app", "1.3.0", SDMMC_SKU)]); + await createDbRelease("system", "1.3.0", 50, [releaseArtifact("system", "1.3.0", SDMMC_SKU)]); + + const inBucket = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "early-adopter", sku: SDMMC_SKU }), inBucket); + expect(jsonBody(inBucket)).toMatchObject({ appVersion: "1.3.0", systemVersion: "1.3.0" }); + + await expect( + Retrieve(createMockRequest({ deviceId: "late-adopter", sku: SDMMC_SKU }), createMockResponse()), + ).rejects.toThrow(/No (app|system) release is rolled out yet for SKU "jetkvm-v2-sdmmc"/); + }); }); describe("signature URL handling", () => { diff --git a/test/sync-releases.test.ts b/test/sync-releases.test.ts index 3d340d4..87e081b 100644 --- a/test/sync-releases.test.ts +++ b/test/sync-releases.test.ts @@ -201,7 +201,7 @@ describe("syncReleases", () => { expect(artifacts).toEqual([]); }); - it("creates new releases at 10% with their S3 artifacts and skips already-synced versions", async () => { + it("creates new releases at 0% with their S3 artifacts and skips already-synced versions", async () => { const version = "9.9.4"; // Pre-existing system row simulates a release the migration (or a prior @@ -249,8 +249,8 @@ describe("syncReleases", () => { where: { version_type: { version: "10.0.0-beta.1", type: "app" } }, }); - // App release is new — created at 10% rollout with a single legacy-compatible artifact. - expect(appRelease.rolloutPercentage).toBe(10); + // App release is new — created at 0% rollout with a single legacy-compatible artifact. + expect(appRelease.rolloutPercentage).toBe(0); expect(appRelease.artifacts).toEqual([ expect.objectContaining({ url: `https://cdn.test.com/app/${version}/jetkvm_app`, @@ -480,7 +480,7 @@ describe("scheduleReleaseSync", () => { const release = await testPrisma.release.findUnique({ where: { version_type: { version, type: "app" } }, }); - expect(release?.rolloutPercentage).toBe(10); + expect(release?.rolloutPercentage).toBe(0); }); }); @@ -551,7 +551,7 @@ describe("Sync handler", () => { await testPrisma.release.findUnique({ where: { version_type: { version: FRESH_VERSION, type: "app" } }, }), - ).toMatchObject({ rolloutPercentage: 10 }); + ).toMatchObject({ rolloutPercentage: 0 }); expect( await testPrisma.release.findUnique({ where: { version_type: { version: OTHER_FRESH_VERSION, type: "app" } },