Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/release-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 13 additions & 7 deletions src/releases.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<DbRelease | null> {
const rolledOutReleases = await prisma.release.findMany({
Expand All @@ -447,9 +450,7 @@ async function getDefaultRelease(prefix: string, sku: string): Promise<DbRelease
const compatibleReleases = rolledOutReleases.filter(r => 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(
Expand All @@ -468,17 +469,22 @@ async function getDefaultRelease(prefix: string, sku: string): Promise<DbRelease
return latestDefaultRelease;
}

/**
* Newest release that is rolling out at all. A row at 0% is registered but
* not yet released, so it must not displace an older release mid-rollout.
*/
async function getLatestRelease(prefix: string, sku: string): Promise<DbRelease> {
return getReleaseByRange(prefix, sku, "*");
return getReleaseByRange(prefix, sku, "*", { rolloutPercentage: { gt: 0 } });
}

async function getReleaseByRange(
prefix: string,
sku: string,
range: string,
where: Prisma.ReleaseWhereInput = {},
): Promise<DbRelease> {
const releases = await prisma.release.findMany({
where: { type: prefix },
where: { type: prefix, ...where },
select: compatibleReleaseSelect(sku),
});

Expand Down
30 changes: 30 additions & 0 deletions test/releases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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", () => {
Expand Down
10 changes: 5 additions & 5 deletions test/sync-releases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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" } },
Expand Down
Loading