Skip to content
2 changes: 1 addition & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ model Release {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
url String
type String @default("app") // "app" or "system"
type String @default("app") // R2 prefix: "app", "system" or "mini" (see src/skus.ts)
hash String
artifacts ReleaseArtifact[]

Expand Down
120 changes: 113 additions & 7 deletions scripts/compare-releases.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ PROD_BASE="${PROD_BASE:-https://api.jetkvm.com}"
# Together they exercise both eligible and ineligible rollout paths.
DEFAULT_DEVICE_IDS=("compare-device-1" "compare-device-2")
DEFAULT_SKUS=("__omit__" "jetkvm-v2" "jetkvm-v2-sdmmc")
# SKUs whose responses are allowed to differ from production until the SKU
# table is deployed there: the two Mini variants (no app artifact, no release
# yet) and one unregistered SKU (400 locally, 404 on the old code).
EXTRA_SKUS="${EXTRA_SKUS:-jetkvm-mini-ethernet jetkvm-mini-wireless jetkvm-v3}"
TRISTATE_VALUES=("__omit__" "false" "true")

TMP_DIR="$(mktemp -d)"
Expand Down Expand Up @@ -43,6 +47,8 @@ Environment overrides:
CURL_TIMEOUT Curl max time in seconds (default: 30)
CURL_CONNECT_TIMEOUT Curl connect timeout in seconds (default: 10)
FAIL_FAST Stop after first failed case (default: true)
EXTRA_SKUS Space-separated SKUs compared with the expected 400-vs-404
deviation accepted (default: both Mini SKUs and jetkvm-v3)

Examples:
scripts/compare-releases.sh
Expand Down Expand Up @@ -160,12 +166,16 @@ try:
except Exception:
normalized_path.write_text(body)
else:
# Resolver bookkeeping that older deploys still serialize; the device
# never reads it, so it is not part of the comparison.
ignored_suffixes = ("CachedAt", "MaxSatisfying")

def scrub(value):
if isinstance(value, dict):
return {
key: scrub(child)
for key, child in value.items()
if not key.endswith("CachedAt")
if not key.endswith(ignored_suffixes)
}
if isinstance(value, list):
return [scrub(item) for item in value]
Expand Down Expand Up @@ -261,8 +271,11 @@ if left_keys != {"name", "message"} or right_keys != {"name", "message"}:
# Both messages mean "no release compatible with this SKU is available";
# the wording differs across deploys but the device sees the same 404.
no_compat_patterns = [
re.compile(r'^Version .+ has no artifact for SKU "([^"]+)"$'),
re.compile(r'^Version .+ predates SKU support and cannot serve SKU "([^"]+)"$'),
re.compile(r'^No default (?:app|system) release available for SKU "([^"]+)"$'),
re.compile(r'^SKU "([^"]+)" is not available for version .+$'),
re.compile(r'^No default (?:app|system|mini) release available for SKU "([^"]+)"$'),
re.compile(r'^No release found for type \S+ and SKU "([^"]+)"$'),
]

def canonicalize(message):
Expand All @@ -286,8 +299,9 @@ is_accepted_deviation() {
local query="$1"
local left_prefix="$2"
local right_prefix="$3"
python3 - "$query" "${left_prefix}.meta" "${right_prefix}.meta" "${left_prefix}.normalized" "${right_prefix}.normalized" <<'PY'
EXTRA_SKUS="$EXTRA_SKUS" python3 - "$query" "${left_prefix}.meta" "${right_prefix}.meta" "${left_prefix}.normalized" "${right_prefix}.normalized" <<'PY'
import json
import os
import sys
from pathlib import Path
from urllib.parse import parse_qs
Expand Down Expand Up @@ -318,6 +332,25 @@ right_meta = parse_meta(right_meta_path)
left_body = load_json(left_body_path)
right_body = load_json(right_body_path)

def accept(reason):
print(reason)
raise SystemExit(0)

# Accepted behavior change:
# For SKUs in EXTRA_SKUS (unregistered, or registered but without releases or
# without the requested artifact) the SKU table answers 400 or 404 with its
# own wording; the code before it answered 400 or 404 from a different check.
# Both sides refusing with a client error means the same thing here.
if one("sku") in os.environ.get("EXTRA_SKUS", "").split():
def refuses(meta, body):
return (
meta.get("http_code") in ("400", "404")
and isinstance(body, dict)
and str(body.get("name", "")).endswith("Error")
)
if refuses(left_meta, left_body) and refuses(right_meta, right_body):
accept("SKU table: both sides refuse this SKU with a 4xx (wording and status may differ)")

# Accepted behavior change:
# Stable requests with prerelease/dev version constraints are DB-only locally.
# Production still resolves those directly from S3. Local 404 vs prod 200 is expected.
Expand All @@ -338,7 +371,7 @@ if not isinstance(left_body, dict) or left_body.get("name") != "NotFoundError":
if not isinstance(right_body, dict) or not right_body.get("appVersion") or not right_body.get("systemVersion"):
raise SystemExit(1)

raise SystemExit(0)
accept("stable dev/prerelease version constraints are DB-only locally")
PY
}

Expand Down Expand Up @@ -494,11 +527,12 @@ write_case_result() {
fi
fi

if (( failed == 1 )) && is_accepted_deviation "$query" "$left_prefix" "$right_prefix"; then
if (( failed == 1 )) && accepted_reason="$(is_accepted_deviation "$query" "$left_prefix" "$right_prefix")"; then
failed=0
accepted_reason="stable dev/prerelease version constraints are DB-only locally"
details=""
mismatch_count=0
else
accepted_reason=""
fi

{
Expand Down Expand Up @@ -704,10 +738,24 @@ STABLE_SYSTEM_VERSION="${stable_versions[1]:-}"
PRERELEASE_APP_VERSION="${prerelease_versions[0]:-}"
PRERELEASE_SYSTEM_VERSION="${prerelease_versions[1]:-}"

# Range constraints around the live stable version: the caret range resolves
# to the newest patch of the current minor, "<stable" to the newest release
# before it, which for app/system reaches back into the pre-skus/ layout.
range_values() {
local version="$1"
[[ -z "$version" ]] && return
local major minor
IFS=. read -r major minor _ <<<"$version"
printf '%s\n' "^${major}.${minor}.0" "<${version}"
}
mapfile -t APP_RANGE_VALUES < <(range_values "$STABLE_APP_VERSION")
mapfile -t SYSTEM_RANGE_VALUES < <(range_values "$STABLE_SYSTEM_VERSION")
read -r -a EXTRA_SKU_LIST <<<"$EXTRA_SKUS"

mapfile -t APP_VERSION_VALUES < <(build_value_set "$STABLE_APP_VERSION" "$PRERELEASE_APP_VERSION")
mapfile -t SYSTEM_VERSION_VALUES < <(build_value_set "$STABLE_SYSTEM_VERSION" "$PRERELEASE_SYSTEM_VERSION")

TOTAL_CASES=$(( ${#DEVICE_IDS[@]} * ${#TRISTATE_VALUES[@]} * ${#APP_VERSION_VALUES[@]} * ${#SYSTEM_VERSION_VALUES[@]} * ${#DEFAULT_SKUS[@]} + ${#TRISTATE_VALUES[@]} * ${#DEFAULT_SKUS[@]} * 2 ))
TOTAL_CASES=$(( ${#DEVICE_IDS[@]} * ${#TRISTATE_VALUES[@]} * ${#APP_VERSION_VALUES[@]} * ${#SYSTEM_VERSION_VALUES[@]} * ${#DEFAULT_SKUS[@]} + ${#TRISTATE_VALUES[@]} * ${#DEFAULT_SKUS[@]} * 2 + ${#DEVICE_IDS[@]} * ${#DEFAULT_SKUS[@]} * (${#APP_RANGE_VALUES[@]} + ${#SYSTEM_RANGE_VALUES[@]}) + ${#EXTRA_SKU_LIST[@]} * 4 ))
declare -A JOB_RESULT_FILES=()
log " total cases: $TOTAL_CASES"
log " parallel: $MAX_PARALLEL"
Expand Down Expand Up @@ -755,6 +803,64 @@ for prerelease in "${TRISTATE_VALUES[@]}"; do
done
done

# Range and older-version constraints, one axis at a time.
for device_id in "${DEVICE_IDS[@]}"; do
for sku in "${DEFAULT_SKUS[@]}"; do
for app_version in "${APP_RANGE_VALUES[@]}"; do
if stop_requested; then
break 3
fi
query_keys=("deviceId" "appVersion" "sku")
query_values=("$device_id" "$app_version" "$sku")
run_case \
"GET /releases deviceId=$device_id appVersion=$app_version sku=$sku" \
"/releases" \
query_keys \
query_values
done
for system_version in "${SYSTEM_RANGE_VALUES[@]}"; do
if stop_requested; then
break 3
fi
query_keys=("deviceId" "systemVersion" "sku")
query_values=("$device_id" "$system_version" "$sku")
run_case \
"GET /releases deviceId=$device_id systemVersion=$system_version sku=$sku" \
"/releases" \
query_keys \
query_values
done
done
done

# SKUs the SKU table treats differently from the code before it.
for sku in "${EXTRA_SKU_LIST[@]}"; do
for prerelease in "__omit__" "true"; do
if stop_requested; then
break 2
fi
query_keys=("deviceId" "prerelease" "sku")
query_values=("${DEVICE_IDS[0]}" "$prerelease" "$sku")
run_case \
"GET /releases deviceId=${DEVICE_IDS[0]} prerelease=$prerelease sku=$sku" \
"/releases" \
query_keys \
query_values
done
query_keys=("sku")
query_values=("$sku")
run_case \
"GET /releases/app/latest sku=$sku" \
"/releases/app/latest" \
query_keys \
query_values
run_case \
"GET /releases/system_recovery/latest sku=$sku" \
"/releases/system_recovery/latest" \
query_keys \
query_values
done

drain_jobs

log
Expand Down
35 changes: 16 additions & 19 deletions scripts/sync-releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,12 @@ import { PrismaClient } from "@prisma/client";
import semver from "semver";

import { objectKeyFromArtifactUrl, streamToString } from "../src/helpers";
import { OTA_PREFIXES, legacyCompatibleSkus, otaFileForPrefix, skusForPrefix } from "../src/skus";

const OTA_ROOT_KEY_FPR = "AF5A36A993D828FEFE7C18C2D1B9856C26A79E95";

type ReleaseType = "app" | "system";
/** An R2 prefix, which is also the Release.type column value. */
type ReleaseType = string;

const DEFAULT_SKU = "jetkvm-v2";
const KNOWN_SKUS = ["jetkvm-v2", "jetkvm-v2-sdmmc"];
const OTA_ROOT_KEY_FPR = "AF5A36A993D828FEFE7C18C2D1B9856C26A79E95";

interface SyncClients {
prisma: PrismaClient;
Expand All @@ -43,16 +42,6 @@ interface ReleaseArtifactInput {
compatibleSkus: string[];
}

function artifactName(type: ReleaseType): string {
return type === "app" ? "jetkvm_app" : "system.tar";
}

// Pre-SKU artifacts (no skus/ folder) are only safe on the original jetkvm-v2.
// Other SKUs require an explicit skus/<sku>/ upload to opt in.
function legacyCompatibleSkus(): string[] {
return [DEFAULT_SKU];
}

const DEFAULT_ROLLOUT_PERCENTAGE = 10;

type ReleaseOutcome =
Expand Down Expand Up @@ -503,10 +492,18 @@ export async function collectReleaseArtifacts(
type: ReleaseType,
version: string,
): Promise<ReleaseArtifactInput[]> {
const skus = config.skus ?? KNOWN_SKUS;
const artifactFileName = artifactName(type);
const skus = config.skus ?? skusForPrefix(type);
const artifactFileName = otaFileForPrefix(type);

if (!(await versionHasSkuSupport(clients.s3Client, config.bucketName, type, version))) {
// Pre-SKU artifacts (no skus/ folder) are only safe on the SKUs that
// predate the layout. A type with no legacy form treats a version
// without skus/ as an upload mistake, not a release.
const compatibleSkus = legacyCompatibleSkus(type);
if (compatibleSkus.length === 0) {
return [];
}

const artifactPath = `${type}/${version}/${artifactFileName}`;
const hash = await readHash(clients.s3Client, config.bucketName, artifactPath);
if (!hash) {
Expand All @@ -517,7 +514,7 @@ export async function collectReleaseArtifacts(
{
url: `${config.baseUrl}/${artifactPath}`,
hash,
compatibleSkus: legacyCompatibleSkus(),
compatibleSkus,
},
];
}
Expand Down Expand Up @@ -639,7 +636,7 @@ export async function syncReleases(
};
let abortedAt: { type: ReleaseType; version: string } | null = null;

outer: for (const type of ["app", "system"] as const) {
outer: for (const type of OTA_PREFIXES) {
const versions = await listStableVersions(clients.s3Client, config.bucketName, type);

for (const version of versions) {
Expand Down
37 changes: 23 additions & 14 deletions src/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ import {
import * as crypto from "crypto";
import { authenticated } from "./auth";
import { activeConnections } from "./webrtc-signaling";
import { effectiveSku } from "./skus";

/**
* Version and SKU are known only while the device holds a signaling
* connection; they arrive as request headers and live in memory, not in the
* database. Offline devices report null for both. An online device that
* sends no SKU header is the original hardware.
*/
function liveDeviceState(id: string) {
const conn = activeConnections.get(id);
return {
online: !!conn,
version: conn?.version || null,
sku: conn ? effectiveSku(conn.sku) : null,
};
}

export const List = async (req: express.Request, res: express.Response) => {
const idToken = req.session?.id_token;
Expand All @@ -24,16 +40,10 @@ export const List = async (req: express.Request, res: express.Response) => {
});

return res.json({
devices: devices.map(device => {
const activeDevice = activeConnections.get(device.id);
const version = activeDevice?.[2] || null;

return {
...device,
online: !!activeDevice,
version,
};
}),
devices: devices.map(device => ({
...device,
...liveDeviceState(device.id),
})),
});
} else {
throw new BadRequestError("Token is not from Google");
Expand All @@ -55,7 +65,7 @@ export const Retrieve = async (
});

if (!device) throw new NotFoundError("Device not found");
return res.status(200).json({ device });
return res.status(200).json({ device: { ...device, ...liveDeviceState(device.id) } });
};

export const Update = async (
Expand Down Expand Up @@ -137,9 +147,8 @@ export const Delete = async (
// We just removed the device, so we should close any running open socket connections
const conn = activeConnections.get(id);
if (conn) {
const [socket] = conn;
socket.send("Deregistered from server");
socket.close();
conn.ws.send("Deregistered from server");
conn.ws.close();
}

return res.status(204).send();
Expand Down
Loading
Loading