Skip to content
Merged
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
22 changes: 20 additions & 2 deletions src/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ import { z, ZodError } from "zod";
const DEFAULT_SKU = "jetkvm-v2";
type ReleaseType = "app" | "system";

/**
* Recovery image filename per SKU. eMMC variants are flashed via DFU + the
* Rockchip upgrade tool (RKDevTool format), while SDMMC variants are written
* to a microSD with balenaEtcher (dd-format zip). Add a new SKU here when
* shipping a new hardware variant; unmapped SKUs are rejected so a typo
* doesn't silently fall back to the wrong artifact.
*/
const RECOVERY_ARTIFACT_BY_SKU: Record<string, string> = {
"jetkvm-v2": "update.img",
"jetkvm-v2-sdmmc": "update_sd.img.zip",
};

/** Query param schema builders for common patterns */
const queryString = () =>
z
Expand Down Expand Up @@ -672,6 +684,11 @@ function releaseCacheKey(prefix: string, query: LatestQuery): string {
export const RetrieveLatestSystemRecovery = cachedRedirect(
query => releaseCacheKey("system-recovery", query),
async query => {
const recoveryArtifact = RECOVERY_ARTIFACT_BY_SKU[query.sku];
if (!recoveryArtifact) {
throw new BadRequestError(`Unsupported SKU "${query.sku}"`);
}

// Get the latest system recovery image from S3. It's stored in the system/ folder.
const listCommand = new ListObjectsV2Command({
Bucket: bucketName,
Expand All @@ -698,12 +715,13 @@ export const RetrieveLatestSystemRecovery = cachedRedirect(
throw new NotFoundError("No valid system recovery versions found");
}

// Resolve the artifact path with SKU support (using update.img for recovery)
// Resolve the artifact path with SKU support; the artifact filename
// depends on the SKU (eMMC = update.img, SDMMC = update_sd.img.zip).
const artifactPath = await resolveArtifactPath(
"system",
latestVersion,
query.sku,
"update.img",
recoveryArtifact,
);

const [firmwareFile, hashFile] = await Promise.all([
Expand Down
55 changes: 39 additions & 16 deletions test/releases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1219,7 +1219,7 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => {
});

it("should throw NotFoundError when non-default SKU requested on legacy version", async () => {
const req = createMockRequest({ sku: "jetkvm-2" });
const req = createMockRequest({ sku: SDMMC_SKU });
const res = createMockResponse();

s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
Expand All @@ -1237,23 +1237,23 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => {
);
});

it("redirects to the requested SKU path when the S3 version has SKU support", async () => {
const req = createMockRequest({ sku: "jetkvm-2" });
it("redirects SDMMC SKU to update_sd.img.zip when the S3 version has SKU support", async () => {
const req = createMockRequest({ sku: SDMMC_SKU });
const res = createMockResponse();

s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/2.0.0/" }],
});

const content = "sku-recovery-content";
const content = "sdmmc-recovery-content";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");

mockS3SkuVersionWithContent(
"system",
"2.0.0",
"jetkvm-2",
"update.img",
SDMMC_SKU,
"update_sd.img.zip",
content,
hash,
);
Expand All @@ -1262,7 +1262,26 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => {

expect(res.redirect).toHaveBeenCalledWith(
302,
"https://cdn.test.com/system/2.0.0/skus/jetkvm-2/update.img",
`https://cdn.test.com/system/2.0.0/skus/${SDMMC_SKU}/update_sd.img.zip`,
);
});

it("should throw BadRequestError for an unmapped SKU", async () => {
const req = createMockRequest({ sku: "jetkvm-future" });
const res = createMockResponse();

// Even though we never reach S3, mock the listing so a regression that
// accepted unknown SKUs would surface as a different kind of failure
// rather than silently returning an unrelated error from S3.
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/1.0.0/" }],
});

await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(
BadRequestError,
);
await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(
'Unsupported SKU "jetkvm-future"',
);
});

Expand Down Expand Up @@ -1295,20 +1314,23 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => {
);
});

it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => {
const req = createMockRequest({ sku: "jetkvm-3" });
it("should throw NotFoundError when SDMMC zip missing on version with SKU support", async () => {
const req = createMockRequest({ sku: SDMMC_SKU });
const res = createMockResponse();

s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/2.0.0/" }],
});

// Version has SKU support (jetkvm-v2 exists) but jetkvm-3 doesn't
// Version has SKU support (jetkvm-v2 exists) but the SDMMC SKU folder
// hasn't shipped update_sd.img.zip for this version yet.
s3Mock.on(ListObjectsV2Command, { Prefix: "system/2.0.0/skus/" }).resolves({
Contents: [{ Key: "system/2.0.0/skus/jetkvm-v2/update.img" }],
});
s3Mock
.on(HeadObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/update.img" })
.on(HeadObjectCommand, {
Key: `system/2.0.0/skus/${SDMMC_SKU}/update_sd.img.zip`,
})
.rejects({
name: "NoSuchKey",
$metadata: { httpStatusCode: 404 },
Expand Down Expand Up @@ -1378,26 +1400,27 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => {
await RetrieveLatestSystemRecovery(req1, res1);
expect(res1._redirectUrl).toBe("https://cdn.test.com/system/1.0.0/update.img");

// Second call with different SKU should NOT use cached result
// Second call with the SDMMC SKU should NOT use the cached eMMC result;
// it must re-resolve and pick up the SDMMC zip path instead.
s3Mock.reset();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/2.0.0/" }],
});
mockS3SkuVersionWithContent(
"system",
"2.0.0",
"jetkvm-2",
"update.img",
SDMMC_SKU,
"update_sd.img.zip",
content,
hash,
);

const req2 = createMockRequest({ sku: "jetkvm-2" });
const req2 = createMockRequest({ sku: SDMMC_SKU });
const res2 = createMockResponse();

await RetrieveLatestSystemRecovery(req2, res2);
expect(res2._redirectUrl).toBe(
"https://cdn.test.com/system/2.0.0/skus/jetkvm-2/update.img",
`https://cdn.test.com/system/2.0.0/skus/${SDMMC_SKU}/update_sd.img.zip`,
);
});
});
Expand Down
Loading