From 03118f1ede57bf4fbb67b7f91f5480f1dbeb23ba Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 09:16:50 -0400 Subject: [PATCH] Support labels on blueprints Ref https://github.com/orgs/fosrl/discussions/2849 --- server/lib/blueprints/labels.ts | 86 +++++++++++++++++++++++ server/lib/blueprints/privateResources.ts | 15 ++++ server/lib/blueprints/publicResources.ts | 10 +++ server/lib/blueprints/types.ts | 4 +- 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 server/lib/blueprints/labels.ts diff --git a/server/lib/blueprints/labels.ts b/server/lib/blueprints/labels.ts new file mode 100644 index 000000000..b98b9f45b --- /dev/null +++ b/server/lib/blueprints/labels.ts @@ -0,0 +1,86 @@ +import { + labels, + resourceLabels, + siteResourceLabels, + Transaction +} from "@server/db"; +import logger from "@server/logger"; +import { and, eq, sql } from "drizzle-orm"; + +// Matches the "gray" swatch in the label color palette used by the UI +// (src/components/labels-selector.tsx), used as the default for labels +// auto-created from a blueprint where no color is specified. +const DEFAULT_LABEL_COLOR = "#b4b4b4"; + +/** + * Looks up labels by name (case-insensitive) within an org, auto-creating + * any that don't already exist. Returns the resolved, de-duplicated labelIds. + */ +export async function getOrCreateLabelIds( + orgId: string, + labelNames: string[], + trx: Transaction +): Promise { + const labelIds = new Set(); + + for (const name of labelNames) { + let [label] = await trx + .select({ labelId: labels.labelId }) + .from(labels) + .where( + and( + eq(labels.orgId, orgId), + sql`LOWER(${labels.name}) = ${name.toLowerCase()}` + ) + ) + .limit(1); + + if (!label) { + [label] = await trx + .insert(labels) + .values({ name, color: DEFAULT_LABEL_COLOR, orgId }) + .returning({ labelId: labels.labelId }); + logger.info( + `Auto-created label "${name}" in org ${orgId} from blueprint` + ); + } + + labelIds.add(label.labelId); + } + + return Array.from(labelIds); +} + +export async function syncResourceLabels( + resourceId: number, + labelIds: number[], + trx: Transaction +) { + await trx + .delete(resourceLabels) + .where(eq(resourceLabels.resourceId, resourceId)); + + if (labelIds.length > 0) { + await trx + .insert(resourceLabels) + .values(labelIds.map((labelId) => ({ resourceId, labelId }))); + } +} + +export async function syncSiteResourceLabels( + siteResourceId: number, + labelIds: number[], + trx: Transaction +) { + await trx + .delete(siteResourceLabels) + .where(eq(siteResourceLabels.siteResourceId, siteResourceId)); + + if (labelIds.length > 0) { + await trx + .insert(siteResourceLabels) + .values( + labelIds.map((labelId) => ({ siteResourceId, labelId })) + ); + } +} diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index f3fa7bf5d..2991a5a83 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -19,6 +19,7 @@ import { import { sites } from "@server/db"; import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm"; import { Config } from "./types"; +import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels"; import logger from "@server/logger"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { getNextAvailableAliasAddress } from "../ip"; @@ -443,6 +444,13 @@ export async function updatePrivateResources( ); } + const labelIds = await getOrCreateLabelIds( + orgId, + resourceData.labels, + trx + ); + await syncSiteResourceLabels(siteResourceId, labelIds, trx); + results.push({ newSiteResource: updatedResource, oldSiteResource: existingResource, @@ -697,6 +705,13 @@ export async function updatePrivateResources( await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx); + const labelIds = await getOrCreateLabelIds( + orgId, + resourceData.labels, + trx + ); + await syncSiteResourceLabels(siteResourceId, labelIds, trx); + results.push({ newSiteResource: newResource, newSites: allSites, diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 997d56e2a..f21bf0f26 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -50,6 +50,7 @@ import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm"; import { tierMatrix } from "../billing/tierMatrix"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { Config, isTargetsOnlyResource, TargetData } from "./types"; +import { getOrCreateLabelIds, syncResourceLabels } from "./labels"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import next from "next"; @@ -1351,6 +1352,15 @@ export async function updatePublicResources( logger.debug(`Created resource ${newResource.resourceId}`); } + if (!isTargetsOnlyResource(resourceData)) { + const labelIds = await getOrCreateLabelIds( + orgId, + resourceData.labels || [], + trx + ); + await syncResourceLabels(resource.resourceId, labelIds, trx); + } + results.push({ proxyResource: resource, targetsToUpdate, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 299819656..e1952848f 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -225,7 +225,8 @@ export const PublicResourceSchema = z maintenance: MaintenanceSchema.optional(), "auth-daemon": AuthDaemonSchema.optional(), "proxy-protocol": z.boolean().optional(), - "proxy-protocol-version": z.int().min(1).optional() + "proxy-protocol-version": z.int().min(1).optional(), + labels: z.array(z.string().min(1)).optional() }) .refine( (resource) => { @@ -493,6 +494,7 @@ export const PrivateResourceSchema = z }), users: z.array(z.string()).optional().default([]), machines: z.array(z.string()).optional().default([]), + labels: z.array(z.string().min(1)).optional().default([]), "auth-daemon": AuthDaemonSchema.optional() }) .refine(