mirror of
https://github.com/jetkvm/cloud-api.git
synced 2026-05-21 05:20:36 +00:00
feat(releases): map recovery artifact filename per SKU (#59)
The system recovery endpoint hard-coded `update.img`, which is the eMMC/RKDevTool image. The SDMMC variant ships as `update_sd.img.zip` (a balenaEtcher-flashable archive), so requesting recovery for the `jetkvm-v2-sdmmc` SKU returned the wrong artifact (or 404'd once SKU folders were enforced). Drive the filename from a small per-SKU map and reject unmapped SKUs with 400 so a typo can't silently fall back to the wrong image.
This commit is contained in:
+20
-2
@@ -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
|
||||
@@ -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,
|
||||
@@ -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([
|
||||
|
||||
+39
-16
@@ -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({
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
@@ -1378,7 +1400,8 @@ 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/" }],
|
||||
@@ -1386,18 +1409,18 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => {
|
||||
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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user