123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { getDbClient } from "@hivegrid/db/client.server";
|
|
import type { DeployMetadata, DeployMigrationInput, DeploySnapshotInput, MigrationState } from "@hivegrid/view-models";
|
|
import { createScopedLogger } from "~/utils/logger.server";
|
|
|
|
const deployLogger = createScopedLogger("admin-deploys");
|
|
|
|
type DbMigrationRow = {
|
|
readonly migration_name: string;
|
|
readonly finished_at: Date | string | null;
|
|
readonly rolled_back_at: Date | string | null;
|
|
readonly logs: string | null;
|
|
};
|
|
|
|
export type DeploySnapshotDeps = {
|
|
readonly env?: NodeJS.ProcessEnv;
|
|
readonly listMigrationNames?: () => readonly string[];
|
|
readonly listAppliedMigrations?: () => Promise<readonly DbMigrationRow[]>;
|
|
};
|
|
|
|
export async function getDeployAdminSnapshot(deps: DeploySnapshotDeps = {}): Promise<DeploySnapshotInput> {
|
|
const env = deps.env ?? process.env;
|
|
const listMigrationNames = deps.listMigrationNames ?? defaultListMigrationNames;
|
|
const listAppliedMigrations = deps.listAppliedMigrations ?? defaultListAppliedMigrations;
|
|
|
|
const migrationNames = [...listMigrationNames()].sort();
|
|
let migrationDbReachable = true;
|
|
let appliedRows: readonly DbMigrationRow[] = [];
|
|
|
|
try {
|
|
appliedRows = await listAppliedMigrations();
|
|
} catch (error) {
|
|
migrationDbReachable = false;
|
|
// The UI can only say "not readable"; without this log the actual cause
|
|
// stays invisible. The historical cause was a missing grant on
|
|
// `_prisma_migrations` for the non-superuser runtime role, which looks
|
|
// exactly like an unreachable database from here.
|
|
deployLogger.error(
|
|
{
|
|
event: "admin_deploys.migration_history_unreadable",
|
|
errorName: error instanceof Error ? error.name : "UnknownError",
|
|
errorMessage: error instanceof Error ? error.message : undefined,
|
|
},
|
|
"Applied migration history could not be read; operator view falls back to unknown state"
|
|
);
|
|
}
|
|
|
|
return {
|
|
metadata: buildDeployMetadata(env),
|
|
migrationDbReachable,
|
|
migrations: buildMigrationRows({ migrationNames, appliedRows, migrationDbReachable }).slice(-20).reverse(),
|
|
};
|
|
}
|
|
|
|
export function buildDeployMetadata(env: NodeJS.ProcessEnv): DeployMetadata {
|
|
const revision = firstPresent(env, ["APP_REVISION", "SENTRY_RELEASE", "GIT_SHA", "COMMIT_SHA", "CI_COMMIT_SHA"]) ?? "unknown";
|
|
return {
|
|
environment: firstPresent(env, ["SENTRY_ENVIRONMENT", "DEPLOY_ENVIRONMENT", "APP_ENV"]) ?? env.NODE_ENV ?? "development",
|
|
nodeEnv: env.NODE_ENV ?? "development",
|
|
release: firstPresent(env, ["SENTRY_RELEASE", "APP_REVISION", "GIT_SHA", "COMMIT_SHA", "CI_COMMIT_SHA"]) ?? "local",
|
|
revision,
|
|
branch: firstPresent(env, ["GIT_BRANCH", "BRANCH_NAME", "CI_COMMIT_BRANCH", "GITEA_REF_NAME", "COOLIFY_BRANCH"]) ?? "unknown",
|
|
image: firstPresent(env, ["DOCKER_IMAGE", "CONTAINER_IMAGE", "IMAGE_TAG", "DOCKER_IMAGE_TAG", "COOLIFY_IMAGE"]) ?? "unknown",
|
|
buildTime: firstPresent(env, ["BUILD_TIME", "BUILD_DATE", "CI_BUILD_CREATED_AT", "SOURCE_DATE_EPOCH"]) ?? "unknown",
|
|
};
|
|
}
|
|
|
|
function buildMigrationRows(input: {
|
|
readonly migrationNames: readonly string[];
|
|
readonly appliedRows: readonly DbMigrationRow[];
|
|
readonly migrationDbReachable: boolean;
|
|
}): DeployMigrationInput[] {
|
|
if (!input.migrationDbReachable) {
|
|
return input.migrationNames.map((name) => ({ name, state: "unknown" }));
|
|
}
|
|
|
|
const appliedByName = new Map(input.appliedRows.map((row) => [row.migration_name, row]));
|
|
|
|
return input.migrationNames.map((name) => {
|
|
const row = appliedByName.get(name);
|
|
if (!row) {
|
|
return { name, state: "pending" };
|
|
}
|
|
return {
|
|
name,
|
|
state: migrationStateFromDb(row),
|
|
appliedAt: row.finished_at ? new Date(row.finished_at).toISOString() : null,
|
|
};
|
|
});
|
|
}
|
|
|
|
function migrationStateFromDb(row: DbMigrationRow): MigrationState {
|
|
if (row.rolled_back_at) return "rolledBack";
|
|
if (!row.finished_at) return "failed";
|
|
if (row.logs?.trim()) return "failed";
|
|
return "applied";
|
|
}
|
|
|
|
function defaultListMigrationNames(): readonly string[] {
|
|
const migrationsRoot = path.resolve(process.cwd(), "prisma/migrations");
|
|
if (!fs.existsSync(migrationsRoot)) {
|
|
return [];
|
|
}
|
|
return fs
|
|
.readdirSync(migrationsRoot, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name);
|
|
}
|
|
|
|
async function defaultListAppliedMigrations(): Promise<readonly DbMigrationRow[]> {
|
|
const db = getDbClient();
|
|
return db.$queryRaw<DbMigrationRow[]>`
|
|
SELECT migration_name, finished_at, rolled_back_at, logs
|
|
FROM "_prisma_migrations"
|
|
ORDER BY migration_name ASC
|
|
`;
|
|
}
|
|
|
|
function firstPresent(env: NodeJS.ProcessEnv, names: readonly string[]): string | undefined {
|
|
return names.map((name) => env[name]?.trim()).find((value): value is string => Boolean(value));
|
|
}
|