mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(enterprise): add upgrade management endpoints (version, migrations, readiness)
This commit is contained in:
@@ -5,6 +5,7 @@ import { registerIpAllowlistRoutes } from "./ip-allowlist.js";
|
||||
import { registerLegalHoldRoutes } from "./legal-hold.js";
|
||||
import { registerScimRoutes } from "./scim.js";
|
||||
import { registerSiemRoutes } from "./siem.js";
|
||||
import { registerUpgradeRoutes } from "./upgrade.js";
|
||||
|
||||
export async function registerEnterpriseRoutes(app: FastifyInstance) {
|
||||
await registerAuditExport(app);
|
||||
@@ -13,4 +14,5 @@ export async function registerEnterpriseRoutes(app: FastifyInstance) {
|
||||
await registerLegalHoldRoutes(app);
|
||||
await registerScimRoutes(app);
|
||||
await registerSiemRoutes(app);
|
||||
await registerUpgradeRoutes(app);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { statfs } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { APP_VERSION } from "@snapotter/shared";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { env } from "../../config.js";
|
||||
import { db, schema } from "../../db/index.js";
|
||||
import { pingRedis } from "../../jobs/connection.js";
|
||||
import { requirePermission } from "../../permissions.js";
|
||||
|
||||
function requireUpgradeFeature(reply: FastifyReply): Promise<boolean> {
|
||||
return (async () => {
|
||||
try {
|
||||
const { isFeatureEnabled } = await import("@snapotter/enterprise");
|
||||
if (isFeatureEnabled("upgrade_management")) return true;
|
||||
} catch {
|
||||
// Enterprise package not available
|
||||
}
|
||||
reply.status(403).send({
|
||||
error: "Upgrade management requires a license with the upgrade_management feature",
|
||||
});
|
||||
return false;
|
||||
})();
|
||||
}
|
||||
|
||||
function readJournal(): { version: string; entries: JournalEntry[] } | null {
|
||||
try {
|
||||
const journalPath = join(process.cwd(), "drizzle", "meta", "_journal.json");
|
||||
return JSON.parse(readFileSync(journalPath, "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface JournalEntry {
|
||||
idx: number;
|
||||
version: string;
|
||||
when: number;
|
||||
tag: string;
|
||||
breakpoints: boolean;
|
||||
}
|
||||
|
||||
export async function registerUpgradeRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/v1/admin/version
|
||||
app.get("/api/v1/admin/version", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = await requirePermission("system:health")(request, reply);
|
||||
if (!user) return;
|
||||
if (!(await requireUpgradeFeature(reply))) return;
|
||||
|
||||
const journal = readJournal();
|
||||
const lastEntry = journal?.entries.at(-1);
|
||||
|
||||
return reply.send({
|
||||
version: APP_VERSION,
|
||||
buildDate: process.env.BUILD_DATE || null,
|
||||
nodeVersion: process.version,
|
||||
schemaVersion: lastEntry ? String(lastEntry.idx).padStart(4, "0") : null,
|
||||
pendingMigrations: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/v1/admin/migrations/pending
|
||||
app.get(
|
||||
"/api/v1/admin/migrations/pending",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = await requirePermission("system:health")(request, reply);
|
||||
if (!user) return;
|
||||
if (!(await requireUpgradeFeature(reply))) return;
|
||||
|
||||
const journal = readJournal();
|
||||
if (!journal) {
|
||||
return reply.status(500).send({ error: "Could not read migration journal" });
|
||||
}
|
||||
|
||||
// Try to read applied migrations from DB
|
||||
let applied: string[] = [];
|
||||
try {
|
||||
const rows = await db.execute(
|
||||
sql`SELECT hash FROM __drizzle_migrations ORDER BY created_at`,
|
||||
);
|
||||
applied = (rows.rows as { hash: string }[]).map((r) => r.hash);
|
||||
} catch {
|
||||
// Table may not exist yet
|
||||
}
|
||||
|
||||
const migrations = journal.entries.map((entry) => ({
|
||||
idx: entry.idx,
|
||||
tag: entry.tag,
|
||||
when: entry.when,
|
||||
applied: applied.includes(entry.tag),
|
||||
}));
|
||||
|
||||
const appliedCount = migrations.filter((m) => m.applied).length;
|
||||
|
||||
return reply.send({
|
||||
migrations,
|
||||
appliedCount,
|
||||
totalCount: journal.entries.length,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/v1/admin/upgrade-check
|
||||
app.get("/api/v1/admin/upgrade-check", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = await requirePermission("system:health")(request, reply);
|
||||
if (!user) return;
|
||||
if (!(await requireUpgradeFeature(reply))) return;
|
||||
|
||||
// Check database connectivity
|
||||
let dbOk = false;
|
||||
try {
|
||||
await db.select().from(schema.settings).limit(1);
|
||||
dbOk = true;
|
||||
} catch {
|
||||
/* db unreachable */
|
||||
}
|
||||
|
||||
// Check Redis connectivity
|
||||
let redisOk = false;
|
||||
try {
|
||||
redisOk = await pingRedis();
|
||||
} catch {
|
||||
/* redis unreachable */
|
||||
}
|
||||
|
||||
// Check in-flight jobs
|
||||
let activeCount = 0;
|
||||
let noInFlightJobs = true;
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.jobs)
|
||||
.where(sql`${schema.jobs.status} IN ('queued', 'processing')`);
|
||||
activeCount = row?.count ?? 0;
|
||||
noInFlightJobs = activeCount === 0;
|
||||
} catch {
|
||||
// If we can't query, assume there may be jobs
|
||||
noInFlightJobs = false;
|
||||
}
|
||||
|
||||
// Check disk space (> 1 GB free)
|
||||
const MIN_FREE_BYTES = 1024 * 1024 * 1024; // 1 GB
|
||||
let diskOk = true;
|
||||
let freeGb = 0;
|
||||
if (env.STORAGE_MODE !== "s3") {
|
||||
try {
|
||||
const stats = await statfs(env.WORKSPACE_PATH);
|
||||
const freeBytes = stats.bfree * stats.bsize;
|
||||
freeGb = Math.round((freeBytes / (1024 * 1024 * 1024)) * 100) / 100;
|
||||
diskOk = freeBytes > MIN_FREE_BYTES;
|
||||
} catch {
|
||||
// Path doesn't exist -- skip check
|
||||
}
|
||||
}
|
||||
|
||||
const ready = diskOk && noInFlightJobs && dbOk && redisOk;
|
||||
|
||||
return reply.send({
|
||||
ready,
|
||||
checks: {
|
||||
diskSpace: { ok: diskOk, freeGb },
|
||||
inFlightJobs: { ok: noInFlightJobs, activeCount },
|
||||
databaseConnected: { ok: dbOk },
|
||||
redisConnected: { ok: redisOk },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.log.info("Enterprise upgrade management routes registered");
|
||||
}
|
||||
Reference in New Issue
Block a user