diff --git a/.dockerignore b/.dockerignore index e4d319e..33d703b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,4 @@ node_modules/ dist/ .DS_Store .env +.git diff --git a/Dockerfile b/Dockerfile index 3825248..ad38c46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,21 @@ -FROM node:21.1.0-alpine AS packages +FROM node:22.23.4-bookworm-slim +LABEL org.opencontainers.image.source="https://github.com/jetkvm/cloud-api" WORKDIR /usr/src/app -COPY LICENSE /usr/src/app/ -COPY package.json /usr/src/app/ -COPY package-lock.json /usr/src/app/ - -RUN npm install +# Prisma needs OpenSSL at runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* -FROM packages AS builder -WORKDIR /usr/src/app +COPY package.json package-lock.json ./ +COPY prisma ./prisma +# ts-node type-checks at startup, so the @types/* devDependencies are needed at runtime +RUN npm ci -COPY . /usr/src/app/ +COPY . . RUN npx prisma generate -RUN npm run build - -FROM packages AS app -LABEL org.opencontainers.image.source="https://github.com/jetkvm/cloud-api" -WORKDIR /usr/src/app - -COPY --from=builder /usr/src/app/prisma /usr/src/app/prisma -COPY --from=builder /usr/src/app/node_modules/.prisma /usr/src/app/node_modules/.prisma -COPY --from=builder /usr/src/app/dist /usr/src/app/dist -COPY .env.example ./.env ENV NODE_ENV=production ENV PORT=3000 EXPOSE 3000 -CMD ["node", "./dist/index.js"] +CMD ["npm", "run", "start"] diff --git a/package.json b/package.json index 4cdeb56..8374041 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test:coverage": "vitest run --coverage" }, "engines": { - "node": "22.x" + "node": ">=22.23.4 <23" }, "keywords": [], "author": "JetKVM", 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" } },