diff --git a/messages/en-US.json b/messages/en-US.json index 77e182f4c..53bff426c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2375,6 +2375,11 @@ "blueprintContentsDescription": "Define the YAML content describing the infrastructure", "blueprintErrorCreateDescription": "An error occurred when applying the blueprint", "blueprintErrorCreate": "Error creating blueprint", + "exportAsBlueprint": "Export as Blueprint", + "exportBlueprintTitle": "Export Blueprint", + "exportBlueprintDescription": "Download a blueprint YAML snippet for \"{name}\" that can be applied to recreate this resource.", + "exportBlueprintError": "Failed to generate blueprint", + "download": "Download", "searchBlueprintProgress": "Search blueprints...", "appliedAt": "Applied At", "source": "Source", diff --git a/server/lib/blueprints/exportResourceBlueprint.ts b/server/lib/blueprints/exportResourceBlueprint.ts new file mode 100644 index 000000000..5b825e74c --- /dev/null +++ b/server/lib/blueprints/exportResourceBlueprint.ts @@ -0,0 +1,682 @@ +import { stringify as stringifyYaml } from "yaml"; +import { and, asc, eq, isNull, not, or } from "drizzle-orm"; +import { + aiBudgets, + aiModels, + clients, + clientSiteResources, + db, + labels, + resourceAiModels, + resourceHeaderAuth, + resourceLabels, + resourcePassword, + resourcePincode, + resourcePolicies, + resourceRules, + resources, + resourceWhitelist, + roles, + roleResources, + roleSiteResources, + siteNetworks, + siteResourceAiModels, + siteResourceLabels, + siteResources, + sites, + targetHealthCheck, + targets, + userResources, + userSiteResources, + users +} from "@server/db"; +import { queryResourcePolicy } from "@server/routers/policy/getResourcePolicy"; +import { + listPublicResourceAiProviders, + listSiteResourceAiProviders +} from "@server/lib/aiInferenceResource"; + +const HTTP_LIKE_PUBLIC_MODES = ["http", "ssh", "rdp", "vnc", "inference"]; + +function reverseRuleAction(action: string): "allow" | "deny" | "pass" { + if (action === "ACCEPT") return "allow"; + if (action === "PASS") return "pass"; + return "deny"; +} + +function reverseRuleMatch(match: string): string { + return match.toLowerCase(); +} + +function stripUndefined>(obj: T): T { + for (const key of Object.keys(obj)) { + if (obj[key] === undefined) { + delete obj[key]; + } + } + return obj; +} + +function buildHealthcheck(row: { + hcHostname: string | null; + hcPort: number | null; + hcEnabled: boolean | null; + hcPath: string | null; + hcScheme: string | null; + hcMode: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcMethod: string | null; + hcStatus: number | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; +}) { + if (!row.hcHostname) { + return undefined; + } + + let headers: { name: string; value: string }[] | undefined; + if (row.hcHeaders) { + try { + headers = JSON.parse(row.hcHeaders); + } catch { + headers = undefined; + } + } + + return stripUndefined({ + hostname: row.hcHostname, + port: row.hcPort ?? undefined, + enabled: row.hcEnabled ?? undefined, + path: row.hcPath ?? undefined, + scheme: row.hcScheme ?? undefined, + mode: row.hcMode ?? undefined, + interval: row.hcInterval ?? undefined, + "unhealthy-interval": row.hcUnhealthyInterval ?? undefined, + timeout: row.hcTimeout ?? undefined, + headers, + "follow-redirects": row.hcFollowRedirects ?? undefined, + method: row.hcMethod ?? undefined, + status: row.hcStatus ?? undefined, + "healthy-threshold": row.hcHealthyThreshold ?? undefined, + "unhealthy-threshold": row.hcUnhealthyThreshold ?? undefined + }); +} + +async function buildPublicAuthAndRules(resource: { + resourceId: number; + resourcePolicyId: number | null; + defaultResourcePolicyId: number | null; + sso: boolean | null; + skipToIdpId: number | null; +}): Promise<{ + policy: string | undefined; + auth: Record | undefined; + rules: Record[]; + notes: string[]; +}> { + const notes: string[] = []; + + if (resource.resourcePolicyId) { + // Shared policy attached: the policy's own auth config lives on the + // policy entity itself (edited separately). The resource only carries + // its own overrides in the legacy resource-level tables. + const [policyRow] = await db + .select({ niceId: resourcePolicies.niceId }) + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resource.resourcePolicyId)) + .limit(1); + + const [roleRows, userRows, whitelistRows, ruleRows, passwordRow, pincodeRow, headerAuthRow] = + await Promise.all([ + db + .select({ name: roles.name }) + .from(roleResources) + .innerJoin( + roles, + and( + eq(roleResources.roleId, roles.roleId), + or(isNull(roles.isAdmin), not(roles.isAdmin)) + ) + ) + .where(eq(roleResources.resourceId, resource.resourceId)), + db + .select({ username: users.username }) + .from(userResources) + .innerJoin(users, eq(userResources.userId, users.userId)) + .where(eq(userResources.resourceId, resource.resourceId)), + db + .select({ email: resourceWhitelist.email }) + .from(resourceWhitelist) + .where(eq(resourceWhitelist.resourceId, resource.resourceId)), + db + .select({ + enabled: resourceRules.enabled, + priority: resourceRules.priority, + action: resourceRules.action, + match: resourceRules.match, + value: resourceRules.value + }) + .from(resourceRules) + .where(eq(resourceRules.resourceId, resource.resourceId)), + db + .select({ passwordId: resourcePassword.passwordId }) + .from(resourcePassword) + .where(eq(resourcePassword.resourceId, resource.resourceId)) + .limit(1), + db + .select({ pincodeId: resourcePincode.pincodeId }) + .from(resourcePincode) + .where(eq(resourcePincode.resourceId, resource.resourceId)) + .limit(1), + db + .select({ headerAuthId: resourceHeaderAuth.headerAuthId }) + .from(resourceHeaderAuth) + .where(eq(resourceHeaderAuth.resourceId, resource.resourceId)) + .limit(1) + ]); + + if (passwordRow.length > 0) { + notes.push( + "A password is configured on this resource. Passwords cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + if (pincodeRow.length > 0) { + notes.push( + "A pincode is configured on this resource. Pincodes cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + if (headerAuthRow.length > 0) { + notes.push( + "Basic auth is configured on this resource. Basic auth credentials cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + + const auth = stripUndefined({ + "sso-enabled": resource.sso ?? undefined, + "sso-roles": roleRows.length ? roleRows.map((r) => r.name) : undefined, + "sso-users": userRows.length + ? userRows.map((u) => u.username) + : undefined, + "whitelist-users": whitelistRows.length + ? whitelistRows.map((w) => w.email) + : undefined, + "auto-login-idp": resource.skipToIdpId ?? undefined + }); + + return { + policy: policyRow?.niceId, + auth: Object.keys(auth).length > 0 ? auth : undefined, + rules: ruleRows.map((r) => + stripUndefined({ + action: reverseRuleAction(r.action), + match: reverseRuleMatch(r.match), + value: r.value, + priority: r.priority, + enabled: r.enabled + }) + ), + notes + }; + } + + if (!resource.defaultResourcePolicyId) { + return { policy: undefined, auth: undefined, rules: [], notes }; + } + + // Inline policy: the resource's own default (non-shared) policy carries + // all of its auth config. + const policy = await queryResourcePolicy({ + resourcePolicyId: resource.defaultResourcePolicyId + }); + + if (!policy) { + return { policy: undefined, auth: undefined, rules: [], notes }; + } + + if (policy.passwordId) { + notes.push( + "A password is configured on this resource. Passwords cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + if (policy.pincodeId) { + notes.push( + "A pincode is configured on this resource. Pincodes cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + if (policy.headerAuth?.id) { + notes.push( + "Basic auth is configured on this resource. Basic auth credentials cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + + const auth = stripUndefined({ + "sso-enabled": policy.sso ?? undefined, + "sso-roles": policy.roles.length + ? policy.roles.map((r) => r.name) + : undefined, + "sso-users": policy.users.length + ? policy.users.map((u) => u.username) + : undefined, + "whitelist-users": policy.emailWhiteList.length + ? policy.emailWhiteList.map((w) => w.email) + : undefined, + "auto-login-idp": policy.idpId ?? undefined + }); + + return { + policy: undefined, + auth: Object.keys(auth).length > 0 ? auth : undefined, + rules: policy.rules.map((r) => + stripUndefined({ + action: reverseRuleAction(r.action), + match: reverseRuleMatch(r.match), + value: r.value, + priority: r.priority, + enabled: r.enabled + }) + ), + notes + }; +} + +async function buildAiProvidersAndBudget(input: + | { scope: "public"; resourceId: number } + | { scope: "site"; siteResourceId: number } +) { + const providers = + input.scope === "public" + ? await listPublicResourceAiProviders(input.resourceId) + : await listSiteResourceAiProviders(input.siteResourceId); + + const aiProvidersConfig = await Promise.all( + providers.map(async (p) => { + const entry: Record = { + provider: p.niceId, + "access-mode": p.accessMode, + enabled: p.enabled + }; + + if (p.accessMode === "select") { + const modelRows = + input.scope === "public" + ? await db + .select({ modelKey: aiModels.modelKey }) + .from(resourceAiModels) + .innerJoin( + aiModels, + eq(resourceAiModels.modelId, aiModels.modelId) + ) + .where( + and( + eq( + resourceAiModels.resourceId, + input.resourceId + ), + eq(aiModels.providerId, p.providerId) + ) + ) + : await db + .select({ modelKey: aiModels.modelKey }) + .from(siteResourceAiModels) + .innerJoin( + aiModels, + eq(siteResourceAiModels.modelId, aiModels.modelId) + ) + .where( + and( + eq( + siteResourceAiModels.siteResourceId, + input.siteResourceId + ), + eq(aiModels.providerId, p.providerId) + ) + ); + + if (modelRows.length > 0) { + entry.models = modelRows.map((m) => m.modelKey); + } + } + + return entry; + }) + ); + + const budgetRows = await db + .select() + .from(aiBudgets) + .where( + input.scope === "public" + ? eq(aiBudgets.resourceId, input.resourceId) + : eq(aiBudgets.siteResourceId, input.siteResourceId) + ); + + const aiBudgetConfig = budgetRows.map((b) => ({ + amount: b.amount, + unit: b.unit, + period: b.period, + enforcement: b.enforcement, + enabled: b.enabled + })); + + return { aiProvidersConfig, aiBudgetConfig }; +} + +export async function generatePublicResourceBlueprintYaml( + resourceId: number +): Promise<{ niceId: string; contents: string }> { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + throw new Error(`Resource ${resourceId} not found`); + } + + const mode = resource.mode; + const isHttpLike = HTTP_LIKE_PUBLIC_MODES.includes(mode); + const isInference = mode === "inference"; + + const resourceConfig: Record = { + name: resource.name, + mode, + enabled: resource.enabled + }; + + if (isHttpLike) { + if (resource.fullDomain) { + resourceConfig["full-domain"] = resource.fullDomain; + } + resourceConfig.ssl = resource.ssl; + } else { + if (resource.proxyPort) { + resourceConfig["proxy-port"] = resource.proxyPort; + } + if (mode === "tcp") { + resourceConfig["proxy-protocol"] = resource.proxyProtocol; + resourceConfig["proxy-protocol-version"] = + resource.proxyProtocolVersion ?? undefined; + } + } + + if (resource.setHostHeader) { + resourceConfig["host-header"] = resource.setHostHeader; + } + if (resource.tlsServerName) { + resourceConfig["tls-server-name"] = resource.tlsServerName; + } + if (resource.requestHeaders) { + try { + resourceConfig.requestHeaders = JSON.parse(resource.requestHeaders); + } catch { + // ignore malformed stored headers + } + } + if (resource.responseHeaders) { + try { + resourceConfig.responseHeaders = JSON.parse(resource.responseHeaders); + } catch { + // ignore malformed stored headers + } + } + + if (mode === "ssh") { + resourceConfig["auth-daemon"] = stripUndefined({ + pam: resource.pamMode ?? undefined, + mode: resource.authDaemonMode ?? undefined, + port: resource.authDaemonPort ?? undefined + }); + } + + if (resource.maintenanceModeEnabled) { + resourceConfig.maintenance = stripUndefined({ + enabled: resource.maintenanceModeEnabled, + type: resource.maintenanceModeType ?? undefined, + title: resource.maintenanceTitle ?? undefined, + message: resource.maintenanceMessage ?? undefined, + "estimated-time": resource.maintenanceEstimatedTime ?? undefined + }); + } + + // Labels + const labelRows = await db + .select({ name: labels.name }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .where(eq(resourceLabels.resourceId, resourceId)); + if (labelRows.length > 0) { + resourceConfig.labels = labelRows.map((l) => l.name); + } + + // Auth / rules / shared policy + const { policy, auth, rules, notes } = await buildPublicAuthAndRules( + resource + ); + if (policy) { + resourceConfig.policy = policy; + } + if (auth) { + resourceConfig.auth = auth; + } + if (rules.length > 0) { + resourceConfig.rules = rules; + } + + // Targets (all modes except inference use targets) + if (!isInference) { + const targetRows = await db + .select({ + ip: targets.ip, + method: targets.method, + port: targets.port, + enabled: targets.enabled, + internalPort: targets.internalPort, + path: targets.path, + pathMatchType: targets.pathMatchType, + rewritePath: targets.rewritePath, + rewritePathType: targets.rewritePathType, + priority: targets.priority, + siteNiceId: sites.niceId, + hcEnabled: targetHealthCheck.hcEnabled, + hcPath: targetHealthCheck.hcPath, + hcScheme: targetHealthCheck.hcScheme, + hcMode: targetHealthCheck.hcMode, + hcHostname: targetHealthCheck.hcHostname, + hcPort: targetHealthCheck.hcPort, + hcInterval: targetHealthCheck.hcInterval, + hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, + hcTimeout: targetHealthCheck.hcTimeout, + hcHeaders: targetHealthCheck.hcHeaders, + hcFollowRedirects: targetHealthCheck.hcFollowRedirects, + hcMethod: targetHealthCheck.hcMethod, + hcStatus: targetHealthCheck.hcStatus, + hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold, + hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold + }) + .from(targets) + .leftJoin(sites, eq(sites.siteId, targets.siteId)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) + .where(eq(targets.resourceId, resourceId)) + .orderBy(asc(targets.targetId)); + + resourceConfig.targets = targetRows.map((row) => + stripUndefined({ + site: row.siteNiceId ?? undefined, + method: mode === "http" ? row.method ?? undefined : undefined, + hostname: row.ip, + port: row.port, + enabled: row.enabled, + "internal-port": row.internalPort ?? undefined, + path: row.path ?? undefined, + "path-match": row.pathMatchType ?? undefined, + "rewrite-path": row.rewritePath ?? undefined, + "rewrite-match": row.rewritePathType ?? undefined, + priority: row.priority, + healthcheck: buildHealthcheck(row) + }) + ); + } else { + const { aiProvidersConfig, aiBudgetConfig } = + await buildAiProvidersAndBudget({ scope: "public", resourceId }); + resourceConfig["ai-providers"] = aiProvidersConfig; + if (aiBudgetConfig.length > 0) { + resourceConfig["ai-budget"] = aiBudgetConfig; + } + } + + const config = { + "proxy-resources": { + [resource.niceId]: resourceConfig + } + }; + + const notesPrefix = + notes.length > 0 + ? notes.map((n) => `# ${n}`).join("\n") + "\n\n" + : ""; + + return { + niceId: resource.niceId, + contents: notesPrefix + stringifyYaml(config) + }; +} + +export async function generatePrivateResourceBlueprintYaml( + siteResourceId: number +): Promise<{ niceId: string; contents: string }> { + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + throw new Error(`Site resource ${siteResourceId} not found`); + } + + const mode = siteResource.mode; + const isInference = mode === "inference"; + const isHttpLike = mode === "http" || isInference; + + const resourceConfig: Record = { + name: siteResource.name, + mode, + enabled: siteResource.enabled + }; + + if (!isInference && siteResource.networkId) { + const siteRows = await db + .select({ niceId: sites.niceId }) + .from(siteNetworks) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(eq(siteNetworks.networkId, siteResource.networkId)); + if (siteRows.length > 0) { + resourceConfig.sites = siteRows.map((s) => s.niceId); + } + } + + if (siteResource.destination) { + resourceConfig.destination = siteResource.destination; + } + if (siteResource.destinationPort) { + resourceConfig["destination-port"] = siteResource.destinationPort; + } + + if (!isHttpLike) { + resourceConfig["tcp-ports"] = siteResource.tcpPortRangeString; + resourceConfig["udp-ports"] = siteResource.udpPortRangeString; + resourceConfig["disable-icmp"] = siteResource.disableIcmp; + } + + if (isHttpLike) { + if (siteResource.fullDomain) { + resourceConfig["full-domain"] = siteResource.fullDomain; + } + resourceConfig.ssl = siteResource.ssl; + if (siteResource.scheme) { + resourceConfig.scheme = siteResource.scheme; + } + } + + if (siteResource.alias) { + resourceConfig.alias = siteResource.alias; + } + + if (mode === "ssh") { + resourceConfig["auth-daemon"] = stripUndefined({ + pam: siteResource.pamMode ?? undefined, + mode: siteResource.authDaemonMode ?? undefined, + port: siteResource.authDaemonPort ?? undefined + }); + } + + const [roleRows, userRows, clientRows, labelRows] = await Promise.all([ + db + .select({ name: roles.name }) + .from(roleSiteResources) + .innerJoin( + roles, + and( + eq(roleSiteResources.roleId, roles.roleId), + or(isNull(roles.isAdmin), not(roles.isAdmin)) + ) + ) + .where(eq(roleSiteResources.siteResourceId, siteResourceId)), + db + .select({ username: users.username }) + .from(userSiteResources) + .innerJoin(users, eq(userSiteResources.userId, users.userId)) + .where(eq(userSiteResources.siteResourceId, siteResourceId)), + db + .select({ niceId: clients.niceId }) + .from(clientSiteResources) + .innerJoin(clients, eq(clientSiteResources.clientId, clients.clientId)) + .where(eq(clientSiteResources.siteResourceId, siteResourceId)), + db + .select({ name: labels.name }) + .from(siteResourceLabels) + .innerJoin(labels, eq(siteResourceLabels.labelId, labels.labelId)) + .where(eq(siteResourceLabels.siteResourceId, siteResourceId)) + ]); + + if (roleRows.length > 0) { + resourceConfig.roles = roleRows.map((r) => r.name); + } + if (userRows.length > 0) { + resourceConfig.users = userRows.map((u) => u.username); + } + if (clientRows.length > 0) { + resourceConfig.machines = clientRows.map((c) => c.niceId); + } + if (labelRows.length > 0) { + resourceConfig.labels = labelRows.map((l) => l.name); + } + + if (isInference) { + const { aiProvidersConfig, aiBudgetConfig } = + await buildAiProvidersAndBudget({ scope: "site", siteResourceId }); + resourceConfig["ai-providers"] = aiProvidersConfig; + if (aiBudgetConfig.length > 0) { + resourceConfig["ai-budget"] = aiBudgetConfig; + } + } + + const config = { + "client-resources": { + [siteResource.niceId]: resourceConfig + } + }; + + return { + niceId: siteResource.niceId, + contents: stringifyYaml(config) + }; +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 33d1fea6c..382e3e826 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -386,6 +386,13 @@ authenticated.get( siteResource.getSiteResource ); +authenticated.get( + "/site-resource/:siteResourceId/blueprint", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.getSiteResource), + siteResource.exportSiteResourceBlueprint +); + authenticated.post( "/site-resource/:siteResourceId", verifySiteResourceAccess, @@ -764,6 +771,13 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getResource), resource.getResource ); + +authenticated.get( + "/resource/:resourceId/blueprint", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.getResource), + resource.exportResourceBlueprint +); authenticated.get( "/org/:orgId/resource/:niceId", verifyOrgAccess, diff --git a/server/routers/resource/exportResourceBlueprint.ts b/server/routers/resource/exportResourceBlueprint.ts new file mode 100644 index 000000000..3e6d8e1a2 --- /dev/null +++ b/server/routers/resource/exportResourceBlueprint.ts @@ -0,0 +1,100 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { generatePublicResourceBlueprintYaml } from "@server/lib/blueprints/exportResourceBlueprint"; + +const exportResourceBlueprintSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +export type ExportResourceBlueprintResponse = { + name: string; + contents: string; +}; + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/blueprint", + description: + "Generate a blueprint YAML snippet that recreates this resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Blueprint], + request: { + params: exportResourceBlueprintSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function exportResourceBlueprint( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = exportResourceBlueprintSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select({ niceId: resources.niceId }) + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const { niceId, contents } = + await generatePublicResourceBlueprintYaml(resourceId); + + return response(res, { + data: { name: niceId, contents }, + success: true, + error: false, + message: "Blueprint generated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : "An error occurred" + ) + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index a09cf0452..071f173db 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -43,3 +43,4 @@ export * from "./listResourceAiProviders"; export * from "./setResourceAiProviders"; export * from "./addAiProviderToResource"; export * from "./removeAiProviderFromResource"; +export * from "./exportResourceBlueprint"; diff --git a/server/routers/siteResource/exportSiteResourceBlueprint.ts b/server/routers/siteResource/exportSiteResourceBlueprint.ts new file mode 100644 index 000000000..e5d92446d --- /dev/null +++ b/server/routers/siteResource/exportSiteResourceBlueprint.ts @@ -0,0 +1,100 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { generatePrivateResourceBlueprintYaml } from "@server/lib/blueprints/exportResourceBlueprint"; + +const exportSiteResourceBlueprintSchema = z.strictObject({ + siteResourceId: z.coerce.number().int().positive() +}); + +export type ExportSiteResourceBlueprintResponse = { + name: string; + contents: string; +}; + +registry.registerPath({ + method: "get", + path: "/site-resource/{siteResourceId}/blueprint", + description: + "Generate a blueprint YAML snippet that recreates this site resource.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.Blueprint], + request: { + params: exportSiteResourceBlueprintSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function exportSiteResourceBlueprint( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = exportSiteResourceBlueprintSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const [siteResource] = await db + .select({ niceId: siteResources.niceId }) + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + const { niceId, contents } = + await generatePrivateResourceBlueprintYaml(siteResourceId); + + return response(res, { + data: { name: niceId, contents }, + success: true, + error: false, + message: "Blueprint generated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : "An error occurred" + ) + ); + } +} diff --git a/server/routers/siteResource/index.ts b/server/routers/siteResource/index.ts index 046ed0a13..243074197 100644 --- a/server/routers/siteResource/index.ts +++ b/server/routers/siteResource/index.ts @@ -25,3 +25,4 @@ export * from "./listSiteResourceAiProviders"; export * from "./setSiteResourceAiProviders"; export * from "./addAiProviderToSiteResource"; export * from "./removeAiProviderFromSiteResource"; +export * from "./exportSiteResourceBlueprint"; diff --git a/src/components/ExportBlueprintModal.tsx b/src/components/ExportBlueprintModal.tsx new file mode 100644 index 000000000..d6ad61440 --- /dev/null +++ b/src/components/ExportBlueprintModal.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Editor from "@monaco-editor/react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { Download, Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type ExportBlueprintModalProps = { + open: boolean; + setOpen: (open: boolean) => void; + endpoint: string; + resourceName: string; +}; + +export default function ExportBlueprintModal({ + open, + setOpen, + endpoint, + resourceName +}: ExportBlueprintModalProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const [loading, setLoading] = useState(false); + const [contents, setContents] = useState(null); + const [fileName, setFileName] = useState(resourceName); + + useEffect(() => { + if (!open) { + return; + } + + setContents(null); + setLoading(true); + + api.get(endpoint) + .then((res) => { + setContents(res.data.data.contents); + setFileName(res.data.data.name || resourceName); + }) + .catch((e) => { + toast({ + variant: "destructive", + title: t("exportBlueprintError"), + description: formatAxiosError( + e, + t("exportBlueprintError") + ) + }); + setOpen(false); + }) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, endpoint]); + + function handleDownload() { + if (!contents) { + return; + } + + const blob = new Blob([contents], { type: "application/x-yaml" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `${fileName}.yaml`); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + window.URL.revokeObjectURL(url); + } + + return ( + + + + {t("exportBlueprintTitle")} + + {t("exportBlueprintDescription", { + name: resourceName + })} + + + +
+ {loading ? ( +
+ +
+ ) : ( + + )} +
+
+ + + + + + +
+
+ ); +} diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 902faf434..61f93a3fe 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -2,6 +2,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; +import ExportBlueprintModal from "@app/components/ExportBlueprintModal"; import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; import { ResourceSitesStatusCell, @@ -171,6 +172,10 @@ export default function PrivateResourcesTable({ useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); + const [exportResource, setExportResource] = + useState(null); + const [isRefreshing, startRefreshTransition] = useTransition(); const { isPaidUser } = usePaidStatus(); @@ -577,6 +582,14 @@ export default function PrivateResourcesTable({ {t("viewSettings")} + { + setExportResource(resourceRow); + setIsExportModalOpen(true); + }} + > + {t("exportAsBlueprint")} + { setSelectedInternalResource( @@ -679,6 +692,22 @@ export default function PrivateResourcesTable({ /> )} + {exportResource && ( + { + setIsExportModalOpen(val); + if (!val) { + setExportResource(null); + } + }} + endpoint={`/site-resource/${exportResource.id}/blueprint`} + resourceName={ + exportResource.niceId || exportResource.name + } + /> + )} + (); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); + const [exportResource, setExportResource] = useState( + null + ); + const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); @@ -617,6 +623,14 @@ export default function PublicResourcesTable({ {t("viewSettings")} + { + setExportResource(resourceRow); + setIsExportModalOpen(true); + }} + > + {t("exportAsBlueprint")} + { setSelectedResource(resourceRow); @@ -720,6 +734,20 @@ export default function PublicResourcesTable({ /> )} + {exportResource && ( + { + setIsExportModalOpen(val); + if (!val) { + setExportResource(null); + } + }} + endpoint={`/resource/${exportResource.id}/blueprint`} + resourceName={exportResource.nice || exportResource.name} + /> + )} +