diff --git a/cli/commands/exportBlueprints.ts b/cli/commands/exportBlueprints.ts new file mode 100644 index 000000000..5d23f7419 --- /dev/null +++ b/cli/commands/exportBlueprints.ts @@ -0,0 +1,119 @@ +import { CommandModule } from "yargs"; +import { stringify as stringifyYaml } from "yaml"; +import { asc, eq } from "drizzle-orm"; +import { db, orgs, resources, siteResources } from "@server/db"; +import { + buildPrivateResourceBlueprintConfig, + buildPublicResourceBlueprintConfig, + formatBlueprintNotes +} from "@server/lib/blueprints/exportResourceBlueprint"; + +type ExportBlueprintsArgs = { + orgId?: string; +}; + +async function buildOrgBlueprint(orgId: string): Promise { + const [publicRows, privateRows] = await Promise.all([ + db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.orgId, orgId)) + .orderBy(asc(resources.resourceId)), + db + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .where(eq(siteResources.orgId, orgId)) + .orderBy(asc(siteResources.siteResourceId)) + ]); + + if (publicRows.length === 0 && privateRows.length === 0) { + return null; + } + + const notes: string[] = []; + const proxyResources: Record = {}; + const clientResources: Record = {}; + + for (const { resourceId } of publicRows) { + const built = await buildPublicResourceBlueprintConfig(resourceId); + proxyResources[built.niceId] = built.config; + notes.push(...built.notes.map((n) => `${built.niceId}: ${n}`)); + } + + for (const { siteResourceId } of privateRows) { + const built = await buildPrivateResourceBlueprintConfig(siteResourceId); + clientResources[built.niceId] = built.config; + } + + const config: Record = {}; + if (publicRows.length > 0) { + config["proxy-resources"] = proxyResources; + } + if (privateRows.length > 0) { + config["client-resources"] = clientResources; + } + + return ( + `# org: ${orgId}\n` + + formatBlueprintNotes(notes) + + stringifyYaml(config) + ); +} + +export const exportBlueprints: CommandModule<{}, ExportBlueprintsArgs> = { + command: "export-blueprints", + describe: + "Export all public resources and site resources as blueprint YAML to stdout (one document per organization)", + builder: (yargs) => { + return yargs.option("orgId", { + type: "string", + describe: "Only export resources for this organization ID" + }); + }, + handler: async (argv: ExportBlueprintsArgs) => { + try { + let orgIds: string[]; + if (argv.orgId) { + const [org] = await db + .select({ orgId: orgs.orgId }) + .from(orgs) + .where(eq(orgs.orgId, argv.orgId)) + .limit(1); + if (!org) { + console.error( + `Error: Organization "${argv.orgId}" not found.` + ); + process.exit(1); + } + orgIds = [org.orgId]; + } else { + const rows = await db + .select({ orgId: orgs.orgId }) + .from(orgs) + .orderBy(asc(orgs.orgId)); + orgIds = rows.map((r) => r.orgId); + } + + // Only YAML goes to stdout so the output can be piped to a file + const documents: string[] = []; + for (const orgId of orgIds) { + const doc = await buildOrgBlueprint(orgId); + if (doc) { + documents.push(doc); + } + } + + if (documents.length === 0) { + console.error("No resources found to export."); + process.exit(0); + } + + process.stdout.write(documents.join("---\n")); + + process.exit(0); + } catch (error) { + console.error("Error exporting blueprints:", error); + process.exit(1); + } + } +}; diff --git a/cli/index.ts b/cli/index.ts index 688416ab6..183316914 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import "@server/extendZod"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { setAdminCredentials } from "./commands/setAdminCredentials"; @@ -12,6 +13,7 @@ import { generateOrgCaKeys } from "./commands/generateOrgCaKeys"; import { clearCertificates } from "./commands/clearCertificates"; import { disableUser2fa } from "./commands/disableUser2fa"; import { setServerAdmin } from "./commands/setServerAdmin"; +import { exportBlueprints } from "./commands/exportBlueprints"; yargs(hideBin(process.argv)) .scriptName("pangctl") @@ -25,5 +27,6 @@ yargs(hideBin(process.argv)) .command(clearCertificates) .command(disableUser2fa) .command(setServerAdmin) + .command(exportBlueprints) .demandCommand() .help().argv; diff --git a/server/lib/blueprints/exportResourceBlueprint.ts b/server/lib/blueprints/exportResourceBlueprint.ts index 9f3f74169..ed5b02bd0 100644 --- a/server/lib/blueprints/exportResourceBlueprint.ts +++ b/server/lib/blueprints/exportResourceBlueprint.ts @@ -365,9 +365,13 @@ async function buildAiProvidersAndBudget(input: return { aiProvidersConfig, aiBudgetConfig }; } -export async function generatePublicResourceBlueprintYaml( +export async function buildPublicResourceBlueprintConfig( resourceId: number -): Promise<{ niceId: string; contents: string }> { +): Promise<{ + niceId: string; + config: Record; + notes: string[]; +}> { const [resource] = await db .select() .from(resources) @@ -539,26 +543,32 @@ export async function generatePublicResourceBlueprintYaml( } } - const config = { - "proxy-resources": { - [resource.niceId]: resourceConfig - } - }; + return { niceId: resource.niceId, config: resourceConfig, notes }; +} - const notesPrefix = - notes.length > 0 - ? notes.map((n) => `# ${n}`).join("\n") + "\n\n" - : ""; +export function formatBlueprintNotes(notes: string[]): string { + return notes.length > 0 + ? notes.map((n) => `# ${n}`).join("\n") + "\n\n" + : ""; +} + +export async function generatePublicResourceBlueprintYaml( + resourceId: number +): Promise<{ niceId: string; contents: string }> { + const { niceId, config, notes } = + await buildPublicResourceBlueprintConfig(resourceId); return { - niceId: resource.niceId, - contents: notesPrefix + stringifyYaml(config) + niceId, + contents: + formatBlueprintNotes(notes) + + stringifyYaml({ "proxy-resources": { [niceId]: config } }) }; } -export async function generatePrivateResourceBlueprintYaml( +export async function buildPrivateResourceBlueprintConfig( siteResourceId: number -): Promise<{ niceId: string; contents: string }> { +): Promise<{ niceId: string; config: Record }> { const [siteResource] = await db .select() .from(siteResources) @@ -676,14 +686,17 @@ export async function generatePrivateResourceBlueprintYaml( } } - const config = { - "client-resources": { - [siteResource.niceId]: resourceConfig - } - }; + return { niceId: siteResource.niceId, config: resourceConfig }; +} + +export async function generatePrivateResourceBlueprintYaml( + siteResourceId: number +): Promise<{ niceId: string; contents: string }> { + const { niceId, config } = + await buildPrivateResourceBlueprintConfig(siteResourceId); return { - niceId: siteResource.niceId, - contents: stringifyYaml(config) + niceId, + contents: stringifyYaml({ "client-resources": { [niceId]: config } }) }; }