206 lines
5.7 KiB
TypeScript
206 lines
5.7 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import cors from "cors";
|
|
import express from "express";
|
|
import { z } from "zod";
|
|
import { AzureImageService } from "./azure-service";
|
|
import { TemplateService } from "./template-service";
|
|
|
|
const findAppRoot = (): string => {
|
|
const candidates = [join(__dirname, "../../.."), join(__dirname, "../..")];
|
|
|
|
for (const candidate of candidates) {
|
|
if (existsSync(join(candidate, "templates.json"))) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
throw new Error("Unable to resolve app root");
|
|
};
|
|
|
|
const queryLocation = z.object({ location: z.string().min(1) });
|
|
const queryOffer = z.object({ location: z.string().min(1), publisher: z.string().min(1) });
|
|
const querySku = z.object({ location: z.string().min(1), publisher: z.string().min(1), offer: z.string().min(1) });
|
|
const queryVersion = z.object({ location: z.string().min(1), publisher: z.string().min(1), offer: z.string().min(1), sku: z.string().min(1) });
|
|
|
|
const renderBody = z.object({
|
|
templateFile: z.string().min(1),
|
|
selection: z.object({
|
|
location: z.string().min(1),
|
|
publisher: z.string().min(1),
|
|
offer: z.string().min(1),
|
|
sku: z.string().min(1),
|
|
version: z.string().min(1)
|
|
})
|
|
});
|
|
|
|
const makeApp = () => {
|
|
const app = express();
|
|
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
|
|
const host = process.env.HOST ?? "0.0.0.0";
|
|
const subscriptionId = process.env.AZURE_SUBSCRIPTION_ID;
|
|
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
|
|
const azure = subscriptionId ? new AzureImageService(subscriptionId) : null;
|
|
const templates = new TemplateService();
|
|
|
|
app.get("/api/health", (_req, res) => {
|
|
if (!subscriptionId) {
|
|
res.status(500).json({
|
|
status: "error",
|
|
message: "Missing AZURE_SUBSCRIPTION_ID"
|
|
});
|
|
return;
|
|
}
|
|
|
|
res.json({ status: "ok" });
|
|
});
|
|
|
|
const requireAzure = (): AzureImageService => {
|
|
if (!azure) {
|
|
throw new Error("Missing AZURE_SUBSCRIPTION_ID");
|
|
}
|
|
|
|
return azure;
|
|
};
|
|
|
|
app.get("/api/locations", async (_req, res, next) => {
|
|
try {
|
|
res.json(await requireAzure().getLocations());
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get("/api/publishers", async (req, res, next) => {
|
|
try {
|
|
const { location } = queryLocation.parse(req.query);
|
|
res.json(await requireAzure().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 requireAzure().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 requireAzure().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 requireAzure().getVersions(location, publisher, offer, sku));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get("/api/vm-skus", async (req, res, next) => {
|
|
try {
|
|
const { location } = queryLocation.parse(req.query);
|
|
res.json(await requireAzure().getVmSkus(location));
|
|
} 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 requireAzure().getSkus(location, publisher, offer);
|
|
res.json({ rendered: templates.buildSkuExport(skus) });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
if (err instanceof 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 = join(findAppRoot(), "dist/frontend");
|
|
if (existsSync(frontendRoot)) {
|
|
app.use(express.static(frontendRoot));
|
|
app.get(/^(?!\/api).*/, (_req, res) => {
|
|
res.sendFile(join(frontendRoot, "index.html"));
|
|
});
|
|
}
|
|
|
|
return { app, port, host };
|
|
};
|
|
|
|
if (require.main === module) {
|
|
const { app, port, host } = makeApp();
|
|
const server = app.listen(port, host, () => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`azure-image-chooser listening on ${host}:${port}`);
|
|
});
|
|
|
|
let shuttingDown = false;
|
|
const shutdown = (signal: NodeJS.Signals) => {
|
|
if (shuttingDown) {
|
|
return;
|
|
}
|
|
|
|
shuttingDown = true;
|
|
// eslint-disable-next-line no-console
|
|
console.log(`received ${signal}, shutting down`);
|
|
server.close((error) => {
|
|
if (error) {
|
|
// eslint-disable-next-line no-console
|
|
console.error("graceful shutdown failed", error);
|
|
process.exit(1);
|
|
}
|
|
|
|
process.exit(0);
|
|
});
|
|
|
|
// Force-exit if connections do not close in time.
|
|
setTimeout(() => {
|
|
// eslint-disable-next-line no-console
|
|
console.error("shutdown timeout reached, forcing exit");
|
|
process.exit(1);
|
|
}, 10_000).unref();
|
|
};
|
|
|
|
process.on("SIGTERM", shutdown);
|
|
process.on("SIGINT", shutdown);
|
|
}
|
|
|
|
export { makeApp };
|