mirror of
https://github.com/jetkvm/cloud-api.git
synced 2026-05-21 05:20:36 +00:00
fix: select compatible OTA releases by SKU
Ensure stable release selection only considers releases with artifacts compatible with the requested SKU, and tighten tests around the DB-backed OTA contract.
This commit is contained in:
+17
-5
@@ -391,6 +391,7 @@ function toRelease(
|
||||
|
||||
function addStableSigUrls(release: Release): void {
|
||||
if (release.appUrl) release.appSigUrl = `${release.appUrl}.sig`;
|
||||
if (release.systemUrl) release.systemSigUrl = `${release.systemUrl}.sig`;
|
||||
}
|
||||
|
||||
async function getReleaseFromS3(
|
||||
@@ -426,6 +427,13 @@ function compatibleArtifactSelect(sku: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function compatibleReleaseWhere(type: ReleaseType, sku: string) {
|
||||
return {
|
||||
type,
|
||||
artifacts: { some: { compatibleSkus: { has: sku } } },
|
||||
} as const;
|
||||
}
|
||||
|
||||
function compatibleReleaseSelect(sku: string) {
|
||||
return {
|
||||
version: true,
|
||||
@@ -457,12 +465,14 @@ function dbReleaseToMetadata(
|
||||
|
||||
async function getDefaultRelease(type: ReleaseType, sku: string): Promise<DbRelease> {
|
||||
const rolledOutReleases = await prisma.release.findMany({
|
||||
where: { rolloutPercentage: 100, type },
|
||||
where: { ...compatibleReleaseWhere(type, sku), rolloutPercentage: 100 },
|
||||
select: compatibleReleaseSelect(sku),
|
||||
});
|
||||
|
||||
if (rolledOutReleases.length === 0) {
|
||||
throw new InternalServerError(`No default release found for type ${type}`);
|
||||
throw new InternalServerError(
|
||||
`No default release found for type ${type} and SKU "${sku}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the latest default version from the rolled out releases
|
||||
@@ -475,7 +485,9 @@ async function getDefaultRelease(type: ReleaseType, sku: string): Promise<DbRele
|
||||
const latestDefaultRelease = rolledOutReleases.find(r => r.version === latestVersion);
|
||||
|
||||
if (!latestDefaultRelease) {
|
||||
throw new InternalServerError(`No default release found for type ${type}`);
|
||||
throw new InternalServerError(
|
||||
`No default release found for type ${type} and SKU "${sku}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return latestDefaultRelease;
|
||||
@@ -491,12 +503,12 @@ async function getReleaseByRange(
|
||||
range: string,
|
||||
): Promise<DbRelease> {
|
||||
const releases = await prisma.release.findMany({
|
||||
where: { type },
|
||||
where: compatibleReleaseWhere(type, sku),
|
||||
select: compatibleReleaseSelect(sku),
|
||||
});
|
||||
|
||||
if (releases.length === 0) {
|
||||
throw new NotFoundError(`No release found for type ${type}`);
|
||||
throw new NotFoundError(`No release found for type ${type} and SKU "${sku}"`);
|
||||
}
|
||||
|
||||
const latestVersion = semver.maxSatisfying(
|
||||
|
||||
+426
-737
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { describe, expect, beforeEach, it } from "vitest";
|
||||
|
||||
import { collectReleaseArtifacts, syncReleases } from "../scripts/sync-releases";
|
||||
import { createAsyncIterable, s3Mock, testPrisma } from "./setup";
|
||||
|
||||
const DEFAULT_SKU = "jetkvm-v2";
|
||||
const SDMMC_SKU = "jetkvm-v2-sdmmc";
|
||||
const SYNC_BUCKET = "test-bucket";
|
||||
const SYNC_BASE_URL = "https://cdn.test.com";
|
||||
const syncS3Client = new S3Client({});
|
||||
|
||||
function mockS3ListVersions(prefix: "app" | "system", versions: string[]) {
|
||||
s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/` }).resolves({
|
||||
CommonPrefixes: versions.map(v => ({ Prefix: `${prefix}/${v}/` })),
|
||||
});
|
||||
}
|
||||
|
||||
function mockS3HashFile(prefix: "app" | "system", version: string, hash: string) {
|
||||
const fileName = prefix === "app" ? "jetkvm_app" : "system.tar";
|
||||
s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({
|
||||
Contents: [],
|
||||
});
|
||||
s3Mock
|
||||
.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}.sha256` })
|
||||
.resolves({
|
||||
Body: createAsyncIterable(hash) as any,
|
||||
});
|
||||
}
|
||||
|
||||
function mockS3SkuVersion(
|
||||
prefix: "app" | "system",
|
||||
version: string,
|
||||
sku: string,
|
||||
hash: string,
|
||||
) {
|
||||
const fileName = prefix === "app" ? "jetkvm_app" : "system.tar";
|
||||
const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`;
|
||||
|
||||
s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({
|
||||
Contents: [{ Key: skuPath }],
|
||||
});
|
||||
s3Mock.on(HeadObjectCommand, { Key: skuPath }).resolves({});
|
||||
s3Mock.on(GetObjectCommand, { Key: `${skuPath}.sha256` }).resolves({
|
||||
Body: createAsyncIterable(hash) as any,
|
||||
});
|
||||
}
|
||||
|
||||
describe("sync-releases script", () => {
|
||||
beforeEach(() => {
|
||||
s3Mock.reset();
|
||||
s3Mock
|
||||
.on(HeadObjectCommand)
|
||||
.rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } });
|
||||
});
|
||||
|
||||
it("marks legacy app artifacts compatible with all known SKUs", async () => {
|
||||
mockS3HashFile("app", "9.9.1", "legacy-app-hash");
|
||||
|
||||
const artifacts = await collectReleaseArtifacts(
|
||||
{ s3Client: syncS3Client },
|
||||
{ bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL },
|
||||
"app",
|
||||
"9.9.1",
|
||||
);
|
||||
|
||||
expect(artifacts).toEqual([
|
||||
{
|
||||
url: "https://cdn.test.com/app/9.9.1/jetkvm_app",
|
||||
hash: "legacy-app-hash",
|
||||
compatibleSkus: [DEFAULT_SKU, SDMMC_SKU],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks legacy system artifacts compatible with only the default SKU", async () => {
|
||||
mockS3HashFile("system", "9.9.2", "legacy-system-hash");
|
||||
|
||||
const artifacts = await collectReleaseArtifacts(
|
||||
{ s3Client: syncS3Client },
|
||||
{ bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL },
|
||||
"system",
|
||||
"9.9.2",
|
||||
);
|
||||
|
||||
expect(artifacts).toEqual([
|
||||
{
|
||||
url: "https://cdn.test.com/system/9.9.2/system.tar",
|
||||
hash: "legacy-system-hash",
|
||||
compatibleSkus: [DEFAULT_SKU],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("collects only SKU artifacts that exist and have a hash", async () => {
|
||||
mockS3SkuVersion("system", "9.9.3", DEFAULT_SKU, "system-default-hash");
|
||||
|
||||
const artifacts = await collectReleaseArtifacts(
|
||||
{ s3Client: syncS3Client },
|
||||
{ bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL },
|
||||
"system",
|
||||
"9.9.3",
|
||||
);
|
||||
|
||||
expect(artifacts).toEqual([
|
||||
{
|
||||
url: `https://cdn.test.com/system/9.9.3/skus/${DEFAULT_SKU}/system.tar`,
|
||||
hash: "system-default-hash",
|
||||
compatibleSkus: [DEFAULT_SKU],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("syncs stable S3 artifacts into DB without changing existing rollout", async () => {
|
||||
const version = "9.9.4";
|
||||
|
||||
await testPrisma.release.create({
|
||||
data: {
|
||||
version,
|
||||
type: "system",
|
||||
rolloutPercentage: 77,
|
||||
url: "https://cdn.test.com/old-system.tar",
|
||||
hash: "old-system-hash",
|
||||
},
|
||||
});
|
||||
|
||||
mockS3ListVersions("app", [version, "10.0.0-beta.1"]);
|
||||
mockS3ListVersions("system", [version]);
|
||||
mockS3HashFile("app", version, "app-hash");
|
||||
mockS3SkuVersion("system", version, DEFAULT_SKU, "system-hash-v2");
|
||||
mockS3SkuVersion("system", version, SDMMC_SKU, "system-hash-sdmmc");
|
||||
|
||||
await syncReleases(
|
||||
{ prisma: testPrisma, s3Client: syncS3Client },
|
||||
{ bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL },
|
||||
);
|
||||
|
||||
const appRelease = await testPrisma.release.findUniqueOrThrow({
|
||||
where: { version_type: { version, type: "app" } },
|
||||
include: { artifacts: true },
|
||||
});
|
||||
const systemRelease = await testPrisma.release.findUniqueOrThrow({
|
||||
where: { version_type: { version, type: "system" } },
|
||||
include: { artifacts: { orderBy: { url: "asc" } } },
|
||||
});
|
||||
const prerelease = await testPrisma.release.findUnique({
|
||||
where: { version_type: { version: "10.0.0-beta.1", type: "app" } },
|
||||
});
|
||||
|
||||
expect(appRelease.rolloutPercentage).toBe(10);
|
||||
expect(appRelease.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
url: `https://cdn.test.com/app/${version}/jetkvm_app`,
|
||||
hash: "app-hash",
|
||||
compatibleSkus: [DEFAULT_SKU, SDMMC_SKU],
|
||||
}),
|
||||
]);
|
||||
expect(systemRelease.rolloutPercentage).toBe(77);
|
||||
expect(systemRelease.artifacts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
url: `https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`,
|
||||
hash: "system-hash-v2",
|
||||
compatibleSkus: [DEFAULT_SKU],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
url: `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`,
|
||||
hash: "system-hash-sdmmc",
|
||||
compatibleSkus: [SDMMC_SKU],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(prerelease).toBeNull();
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 30000,
|
||||
include: ["test/**/*.test.ts"],
|
||||
silent: "passed-only"
|
||||
silent: "passed-only",
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user