Fixes to NodeJS version.

This commit is contained in:
2026-04-19 20:47:47 +02:00
parent aca4998da7
commit 176fa5ead2
28 changed files with 686 additions and 134 deletions

107
app-new/dist/backend/azure-service.js vendored Normal file
View File

@@ -0,0 +1,107 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AzureImageService = void 0;
const arm_compute_1 = require("@azure/arm-compute");
const identity_1 = require("@azure/identity");
const cache_1 = require("./cache");
const version_1 = require("./version");
const CACHE_TTL_MS = 5 * 60 * 1000;
class AzureImageService {
subscriptionId;
credential = new identity_1.DefaultAzureCredential();
computeClient;
cache = new cache_1.MemoryCache();
constructor(subscriptionId) {
this.subscriptionId = subscriptionId;
this.computeClient = new arm_compute_1.ComputeManagementClient(this.credential, subscriptionId);
}
async getLocations() {
const cacheKey = "locations";
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
const token = await this.credential.getToken("https://management.azure.com/.default");
const url = `https://management.azure.com/subscriptions/${this.subscriptionId}/locations?api-version=2022-12-01`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token?.token ?? ""}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch Azure locations: ${response.status} ${response.statusText}`);
}
const payload = (await response.json());
const allLocations = (payload.value ?? [])
.filter((loc) => Boolean(loc.name))
.map((loc) => ({
name: loc.name,
displayName: loc.displayName ?? loc.name,
regionType: loc.metadata?.regionType
}));
const physical = allLocations.filter((loc) => loc.regionType?.toLowerCase() === "physical");
const locations = (physical.length > 0 ? physical : allLocations).map((loc) => ({
name: loc.name,
displayName: loc.displayName
}));
locations.sort((a, b) => a.name.localeCompare(b.name));
this.cache.set(cacheKey, locations, CACHE_TTL_MS);
return locations;
}
async getPublishers(location) {
const cacheKey = `publishers:${location}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
const response = await this.computeClient.virtualMachineImages.listPublishers(location);
const publishers = this.extractNames(response).sort((a, b) => a.localeCompare(b));
this.cache.set(cacheKey, publishers, CACHE_TTL_MS);
return publishers;
}
async getOffers(location, publisher) {
const cacheKey = `offers:${location}:${publisher}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
const response = await this.computeClient.virtualMachineImages.listOffers(location, publisher);
const offers = this.extractNames(response).sort((a, b) => a.localeCompare(b));
this.cache.set(cacheKey, offers, CACHE_TTL_MS);
return offers;
}
async getSkus(location, publisher, offer) {
const cacheKey = `skus:${location}:${publisher}:${offer}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
const response = await this.computeClient.virtualMachineImages.listSkus(location, publisher, offer);
const skus = this.extractNames(response).sort((a, b) => a.localeCompare(b));
this.cache.set(cacheKey, skus, CACHE_TTL_MS);
return skus;
}
async getVersions(location, publisher, offer, sku) {
const cacheKey = `versions:${location}:${publisher}:${offer}:${sku}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
const response = await this.computeClient.virtualMachineImages.list(location, publisher, offer, sku);
const versions = this.extractNames(response);
const sorted = (0, version_1.sortImageVersionsIfSemantic)(versions);
this.cache.set(cacheKey, sorted, CACHE_TTL_MS);
return sorted;
}
extractNames(source) {
const items = Array.isArray(source)
? source
: typeof source === "object" && source !== null && "value" in source && Array.isArray(source.value)
? source.value
: [];
return items
.map((item) => (typeof item === "object" && item !== null && "name" in item ? item.name : undefined))
.filter((value) => Boolean(value));
}
}
exports.AzureImageService = AzureImageService;

24
app-new/dist/backend/cache.js vendored Normal file
View File

@@ -0,0 +1,24 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MemoryCache = void 0;
class MemoryCache {
store = new Map();
get(key) {
const hit = this.store.get(key);
if (!hit) {
return undefined;
}
if (Date.now() >= hit.expiresAt) {
this.store.delete(key);
return undefined;
}
return hit.value;
}
set(key, value, ttlMs) {
this.store.set(key, {
value,
expiresAt: Date.now() + ttlMs
});
}
}
exports.MemoryCache = MemoryCache;

148
app-new/dist/backend/server.js vendored Normal file
View File

@@ -0,0 +1,148 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.makeApp = void 0;
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
const cors_1 = __importDefault(require("cors"));
const express_1 = __importDefault(require("express"));
const zod_1 = require("zod");
const azure_service_1 = require("./azure-service");
const template_service_1 = require("./template-service");
const findAppNewRoot = () => {
const candidates = [(0, node_path_1.join)(__dirname, "../../.."), (0, node_path_1.join)(__dirname, "../..")];
for (const candidate of candidates) {
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(candidate, "templates.json"))) {
return candidate;
}
}
throw new Error("Unable to resolve app-new root");
};
const queryLocation = zod_1.z.object({ location: zod_1.z.string().min(1) });
const queryOffer = zod_1.z.object({ location: zod_1.z.string().min(1), publisher: zod_1.z.string().min(1) });
const querySku = zod_1.z.object({ location: zod_1.z.string().min(1), publisher: zod_1.z.string().min(1), offer: zod_1.z.string().min(1) });
const queryVersion = zod_1.z.object({ location: zod_1.z.string().min(1), publisher: zod_1.z.string().min(1), offer: zod_1.z.string().min(1), sku: zod_1.z.string().min(1) });
const renderBody = zod_1.z.object({
templateFile: zod_1.z.string().min(1),
selection: zod_1.z.object({
location: zod_1.z.string().min(1),
publisher: zod_1.z.string().min(1),
offer: zod_1.z.string().min(1),
sku: zod_1.z.string().min(1),
version: zod_1.z.string().min(1)
})
});
const makeApp = () => {
const app = (0, express_1.default)();
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
const subscriptionId = process.env.AZURE_SUBSCRIPTION_ID;
app.use((0, cors_1.default)());
app.use(express_1.default.json());
if (!subscriptionId) {
app.get("/api/health", (_req, res) => {
res.status(500).json({
status: "error",
message: "Missing AZURE_SUBSCRIPTION_ID"
});
});
return { app, port };
}
const azure = new azure_service_1.AzureImageService(subscriptionId);
const templates = new template_service_1.TemplateService();
app.get("/api/health", (_req, res) => {
res.json({ status: "ok" });
});
app.get("/api/locations", async (_req, res, next) => {
try {
res.json(await azure.getLocations());
}
catch (error) {
next(error);
}
});
app.get("/api/publishers", async (req, res, next) => {
try {
const { location } = queryLocation.parse(req.query);
res.json(await azure.getPublishers(location));
}
catch (error) {
next(error);
}
});
app.get("/api/offers", async (req, res, next) => {
try {
const { location, publisher } = queryOffer.parse(req.query);
res.json(await azure.getOffers(location, publisher));
}
catch (error) {
next(error);
}
});
app.get("/api/skus", async (req, res, next) => {
try {
const { location, publisher, offer } = querySku.parse(req.query);
res.json(await azure.getSkus(location, publisher, offer));
}
catch (error) {
next(error);
}
});
app.get("/api/versions", async (req, res, next) => {
try {
const { location, publisher, offer, sku } = queryVersion.parse(req.query);
res.json(await azure.getVersions(location, publisher, offer, sku));
}
catch (error) {
next(error);
}
});
app.get("/api/templates", (_req, res) => {
res.json(templates.getTemplates());
});
app.post("/api/render", (req, res, next) => {
try {
const payload = renderBody.parse(req.body);
const rendered = templates.render(payload.templateFile, payload.selection);
res.json({ rendered });
}
catch (error) {
next(error);
}
});
app.get("/api/sku-export", async (req, res, next) => {
try {
const { location, publisher, offer } = querySku.parse(req.query);
const skus = await azure.getSkus(location, publisher, offer);
res.json({ rendered: templates.buildSkuExport(skus) });
}
catch (error) {
next(error);
}
});
app.use((err, _req, res, _next) => {
if (err instanceof zod_1.z.ZodError) {
res.status(400).json({ message: "Invalid request", issues: err.issues });
return;
}
const message = err instanceof Error ? err.message : "Unexpected error";
res.status(500).json({ message });
});
const frontendRoot = (0, node_path_1.join)(findAppNewRoot(), "dist/frontend");
if ((0, node_fs_1.existsSync)(frontendRoot)) {
app.use(express_1.default.static(frontendRoot));
app.get(/^(?!\/api).*/, (_req, res) => {
res.sendFile((0, node_path_1.join)(frontendRoot, "index.html"));
});
}
return { app, port };
};
exports.makeApp = makeApp;
if (require.main === module) {
const { app, port } = makeApp();
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`azure-image-chooser listening on ${port}`);
});
}

View File

@@ -0,0 +1,36 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TemplateService = void 0;
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
const nunjucks_1 = __importDefault(require("nunjucks"));
const findAppNewRoot = () => {
const candidates = [(0, node_path_1.join)(__dirname, "../../.."), (0, node_path_1.join)(__dirname, "../..")];
for (const candidate of candidates) {
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(candidate, "templates.json"))) {
return candidate;
}
}
throw new Error("Unable to resolve app-new template root");
};
class TemplateService {
appNewRoot = findAppNewRoot();
env = nunjucks_1.default.configure((0, node_path_1.join)(this.appNewRoot, "templates"), {
autoescape: false,
noCache: true
});
templates = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(this.appNewRoot, "templates.json"), "utf8"));
getTemplates() {
return this.templates;
}
render(templateFile, selection) {
return this.env.render(templateFile, selection);
}
buildSkuExport(skus) {
return `[\n${skus.map((sku) => `\t\"${sku}\"`).join(",\n")}\n]`;
}
}
exports.TemplateService = TemplateService;

2
app-new/dist/backend/types.js vendored Normal file
View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

21
app-new/dist/backend/version.js vendored Normal file
View File

@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.sortImageVersionsIfSemantic = void 0;
const SEMVER_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/;
const semverSortKey = (value) => value.split(".").map((part) => Number.parseInt(part, 10));
const sortImageVersionsIfSemantic = (versions) => {
if (!versions.every((value) => SEMVER_PATTERN.test(value))) {
return [...versions].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
}
return [...versions].sort((a, b) => {
const aParts = semverSortKey(a);
const bParts = semverSortKey(b);
for (let i = 0; i < aParts.length; i += 1) {
if (aParts[i] !== bParts[i]) {
return aParts[i] - bParts[i];
}
}
return 0;
});
};
exports.sortImageVersionsIfSemantic = sortImageVersionsIfSemantic;