diff --git a/src/app/api/v2/builds/[uuid]/metadata/route.ts b/src/app/api/v2/builds/[uuid]/metadata/route.ts new file mode 100644 index 00000000..f8684d06 --- /dev/null +++ b/src/app/api/v2/builds/[uuid]/metadata/route.ts @@ -0,0 +1,86 @@ +/** + * Copyright 2026 Lifecycle contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import 'server/lib/dependencies'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import BuildMetadataService, { BuildMetadataError } from 'server/services/buildMetadata'; + +interface RouteContext { + params: { + uuid: string; + }; +} + +/** + * @openapi + * /api/v2/builds/{uuid}/metadata: + * get: + * summary: Get rendered build metadata + * description: Returns build metadata with configured links rendered for the requested build. + * tags: + * - Builds + * operationId: getBuildMetadata + * parameters: + * - in: path + * name: uuid + * required: true + * schema: + * type: string + * description: The UUID of the build to render metadata for. + * responses: + * '200': + * description: Rendered build metadata. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BuildMetadataSuccessResponse' + * '404': + * description: Build not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '400': + * description: Invalid rendered metadata link. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '500': + * description: Server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const getHandler = async (req: NextRequest, { params }: RouteContext) => { + const service = new BuildMetadataService(); + + try { + const metadata = await service.renderMetadataForBuildUUID(params.uuid); + return successResponse(metadata, { status: 200 }, req); + } catch (error) { + if (error instanceof BuildMetadataError) { + return errorResponse(error, { status: error.code === 'not_found' ? 404 : 400 }, req); + } + + throw error; + } +}; + +export const GET = createApiHandler(getHandler); diff --git a/src/app/api/v2/config/metadata/[id]/route.ts b/src/app/api/v2/config/metadata/[id]/route.ts new file mode 100644 index 00000000..a1f7548f --- /dev/null +++ b/src/app/api/v2/config/metadata/[id]/route.ts @@ -0,0 +1,143 @@ +/** + * Copyright 2026 Lifecycle contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import 'server/lib/dependencies'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import BuildMetadataService, { BuildMetadataError } from 'server/services/buildMetadata'; + +interface RouteContext { + params: { + id: string; + }; +} + +function mapMetadataError(error: unknown, req: NextRequest) { + if (error instanceof BuildMetadataError) { + return errorResponse(error, { status: error.code === 'not_found' ? 404 : 400 }, req); + } + + throw error; +} + +async function readRequestBody(req: NextRequest): Promise { + try { + return await req.json(); + } catch { + throw new BuildMetadataError('Invalid JSON in request body.', 'invalid_input'); + } +} + +/** + * @openapi + * /api/v2/config/metadata/{id}: + * patch: + * summary: Update a build metadata link + * description: Updates selected fields on one configured build metadata link template. + * tags: + * - Config + * operationId: updateBuildMetadataLink + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BuildMetadataLinkPatchRequest' + * responses: + * '200': + * description: Build metadata config after the link was updated. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BuildMetadataSuccessResponse' + * '400': + * description: Invalid metadata link input. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '403': + * description: Forbidden. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Metadata link not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * delete: + * summary: Delete a build metadata link + * description: Removes one configured build metadata link template. + * tags: + * - Config + * operationId: deleteBuildMetadataLink + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * '204': + * description: Metadata link deleted. + * '403': + * description: Forbidden. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Metadata link not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const patchHandler = async (req: NextRequest, { params }: RouteContext) => { + const service = new BuildMetadataService(); + + try { + const body = await readRequestBody(req); + const metadata = await service.updateLink(params.id, body); + return successResponse(metadata, { status: 200 }, req); + } catch (error) { + return mapMetadataError(error, req); + } +}; + +const deleteHandler = async (req: NextRequest, { params }: RouteContext) => { + const service = new BuildMetadataService(); + + try { + await service.deleteLink(params.id); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return mapMetadataError(error, req); + } +}; + +export const PATCH = createApiHandler(patchHandler, { roles: ['admin'] }); +export const DELETE = createApiHandler(deleteHandler, { roles: ['admin'] }); diff --git a/src/app/api/v2/config/metadata/route.ts b/src/app/api/v2/config/metadata/route.ts new file mode 100644 index 00000000..6a5ae929 --- /dev/null +++ b/src/app/api/v2/config/metadata/route.ts @@ -0,0 +1,111 @@ +/** + * Copyright 2026 Lifecycle contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import 'server/lib/dependencies'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import BuildMetadataService, { BuildMetadataError } from 'server/services/buildMetadata'; + +function mapMetadataError(error: unknown, req: NextRequest) { + if (error instanceof BuildMetadataError) { + return errorResponse(error, { status: error.code === 'not_found' ? 404 : 400 }, req); + } + + throw error; +} + +async function readRequestBody(req: NextRequest): Promise { + try { + return await req.json(); + } catch { + throw new BuildMetadataError('Invalid JSON in request body.', 'invalid_input'); + } +} + +/** + * @openapi + * /api/v2/config/metadata: + * get: + * summary: Get build metadata config + * description: Returns the global build metadata configuration, including unrendered link templates. + * tags: + * - Config + * operationId: getBuildMetadataConfig + * responses: + * '200': + * description: Build metadata config. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BuildMetadataSuccessResponse' + * '403': + * description: Forbidden. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * post: + * summary: Create a build metadata link + * description: Adds a link template to the global build metadata configuration. + * tags: + * - Config + * operationId: createBuildMetadataLink + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BuildMetadataLinkCreateRequest' + * responses: + * '201': + * description: Build metadata config after the link was created. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BuildMetadataSuccessResponse' + * '400': + * description: Invalid metadata link input. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '403': + * description: Forbidden. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const getHandler = async (req: NextRequest) => { + const metadata = await new BuildMetadataService().getConfig(); + return successResponse(metadata, { status: 200 }, req); +}; + +const postHandler = async (req: NextRequest) => { + const service = new BuildMetadataService(); + + try { + const body = await readRequestBody(req); + const metadata = await service.createLink(body); + return successResponse(metadata, { status: 201 }, req); + } catch (error) { + return mapMetadataError(error, req); + } +}; + +export const GET = createApiHandler(getHandler, { roles: ['admin'] }); +export const POST = createApiHandler(postHandler, { roles: ['admin'] }); diff --git a/src/pages/api/v1/builds/[uuid]/index.ts b/src/pages/api/v1/builds/[uuid]/index.ts index 29e2034d..eebd38aa 100644 --- a/src/pages/api/v1/builds/[uuid]/index.ts +++ b/src/pages/api/v1/builds/[uuid]/index.ts @@ -17,7 +17,6 @@ import { nanoid } from 'nanoid'; import { NextApiRequest, NextApiResponse } from 'next/types'; import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; -import { Build } from 'server/models'; import BuildService from 'server/services/build'; import OverrideService from 'server/services/override'; @@ -41,7 +40,6 @@ async function retrieveBuild(req: NextApiRequest, res: NextApiResponse) { 'pullRequestId', 'manifest', 'webhooksYaml', - 'dashboardLinks', 'isStatic', 'namespace' ); @@ -70,7 +68,7 @@ async function updateBuild(req: NextApiRequest, res: NextApiResponse, correlatio try { const override = new OverrideService(); - const build: Build = await override.db.models.Build.query().findOne({ uuid }).withGraphFetched('pullRequest'); + const build = await override.db.models.Build.query().findOne({ uuid }).withGraphFetched('pullRequest'); if (!build) { getLogger().debug('Build not found, cannot patch uuid'); @@ -162,8 +160,6 @@ async function updateBuild(req: NextApiRequest, res: NextApiResponse, correlatio * type: object * webhooksYaml: * type: object - * dashboardLinks: - * type: object * isStatic: * type: boolean * namespace: @@ -268,9 +264,6 @@ async function updateBuild(req: NextApiRequest, res: NextApiResponse, correlatio * webhooksYaml: * type: object * example: {} - * dashboardLinks: - * type: object - * example: {} * isStatic: * type: boolean * example: false diff --git a/src/pages/api/v1/pull-requests/[id]/builds.ts b/src/pages/api/v1/pull-requests/[id]/builds.ts index b1827bc9..68743041 100644 --- a/src/pages/api/v1/pull-requests/[id]/builds.ts +++ b/src/pages/api/v1/pull-requests/[id]/builds.ts @@ -74,8 +74,6 @@ import PullRequestService from 'server/services/pullRequest'; * type: object * webhooksYaml: * type: object - * dashboardLinks: - * type: object * isStatic: * type: boolean * 400: @@ -149,7 +147,6 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { 'pullRequestId', 'manifest', 'webhooksYaml', - 'dashboardLinks', 'isStatic' ); diff --git a/src/server/db/migrations/001_seed.ts b/src/server/db/migrations/001_seed.ts index fb31aa3b..7513bce9 100644 --- a/src/server/db/migrations/001_seed.ts +++ b/src/server/db/migrations/001_seed.ts @@ -120,7 +120,6 @@ export async function up(knex: Knex): Promise { "capacityType" varchar(255), "enableFullYaml" boolean DEFAULT false, "webhooksYaml" varchar(4096), - "dashboardLinks" json DEFAULT '{}'::json, "enabledFeatures" json DEFAULT '[]'::json, "isStatic" boolean DEFAULT false, "githubDeployments" boolean DEFAULT false, diff --git a/src/server/db/migrations/023_add_metadata_config.ts b/src/server/db/migrations/023_add_metadata_config.ts new file mode 100644 index 00000000..2ea31938 --- /dev/null +++ b/src/server/db/migrations/023_add_metadata_config.ts @@ -0,0 +1,61 @@ +/** + * Copyright 2026 Lifecycle contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Knex } from 'knex'; + +const DEFAULT_METADATA = { + links: [ + { + id: 'example-container-metrics', + text: 'Container metrics', + icon: 'Container', + link: 'https://example.com/metrics/containers?build={{{buildUUID}}}', + position: 0, + }, + { + id: 'example-application-traces', + text: 'Application traces', + icon: 'Route', + link: 'https://example.com/traces?namespace={{namespace}}', + position: 1, + }, + { + id: 'example-environment-logs', + text: 'Environment logs', + icon: 'FileCog', + link: 'https://example.com/logs?build={{{buildUUID}}}', + position: 2, + }, + ], +}; + +export async function up(knex: Knex): Promise { + await knex('global_config') + .insert({ + key: 'metadata', + config: DEFAULT_METADATA, + createdAt: knex.fn.now(), + updatedAt: knex.fn.now(), + deletedAt: null, + description: 'Build metadata configuration.', + }) + .onConflict('key') + .ignore(); +} + +export async function down(knex: Knex): Promise { + await knex('global_config').where({ key: 'metadata' }).delete(); +} diff --git a/src/server/db/migrations/024_remove_build_dashboard_links.ts b/src/server/db/migrations/024_remove_build_dashboard_links.ts new file mode 100644 index 00000000..acf60b2e --- /dev/null +++ b/src/server/db/migrations/024_remove_build_dashboard_links.ts @@ -0,0 +1,25 @@ +/** + * Copyright 2026 Lifecycle contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.raw('ALTER TABLE builds DROP COLUMN IF EXISTS "dashboardLinks"'); +} + +export async function down(knex: Knex): Promise { + await knex.schema.raw('ALTER TABLE builds ADD COLUMN IF NOT EXISTS "dashboardLinks" json DEFAULT \'{}\'::json'); +} diff --git a/src/server/lib/envVariables.ts b/src/server/lib/envVariables.ts index bb388028..3fa067e5 100644 --- a/src/server/lib/envVariables.ts +++ b/src/server/lib/envVariables.ts @@ -43,6 +43,10 @@ const ALLOWED_PROPERTIES = [ 'namespace', ]; +type EnvironmentVariableDictionaryOptions = { + applyNoDefaultEnvResolveFeatureFlag?: boolean; +}; + export abstract class EnvironmentVariables { db: Database; @@ -68,9 +72,11 @@ export abstract class EnvironmentVariables { buildUUID: string, fullYamlSupport: boolean, build: Build, - additionalVariables?: Record + additionalVariables?: Record, + options: EnvironmentVariableDictionaryOptions = {} ): Promise> { let availableEnv: Record; + const applyNoDefaultEnvResolveFeatureFlag = options.applyNoDefaultEnvResolveFeatureFlag ?? true; if (fullYamlSupport) { availableEnv = deploys @@ -78,41 +84,44 @@ export abstract class EnvironmentVariables { return deploy.deployable?.type !== DeployTypes.CONFIGURATION; }) .reduce((env, deploy) => { - const serviceEnv: Array<[string, string]> = ALLOWED_PROPERTIES.map((prop) => { - let propValue = null; + const deployable = deploy.deployable; + if (deployable == null) { + return env; + } + + const serviceEnv: Array<[string, string | null | undefined]> = ALLOWED_PROPERTIES.map((prop) => { + let propValue: string | null | undefined = null; if (deploy.active) { propValue = deploy[prop]; if (prop === 'UUID') { - propValue = deploy.deployable.buildUUID; + propValue = deployable.buildUUID; } } else { if (prop === 'UUID') { - propValue = deploy.deployable.defaultUUID; + propValue = deployable.defaultUUID; } else if (prop === 'publicUrl') { - propValue = deploy.deployable.defaultPublicUrl; + propValue = deployable.defaultPublicUrl; } else if (prop === 'internalHostname') { if ( + applyNoDefaultEnvResolveFeatureFlag && Array.isArray(build?.enabledFeatures) && build.enabledFeatures.includes(FeatureFlags.NO_DEFAULT_ENV_RESOLVE) ) { propValue = NO_DEFAULT_ENV_UUID; } else { - propValue = deploy.deployable.defaultInternalHostname; + propValue = deployable.defaultInternalHostname; } } else { propValue = ''; } } - return [`${deploy.deployable.name.replace(/-/g, HYPHEN_REPLACEMENT)}_${prop}`, propValue]; + return [`${deployable.name.replace(/-/g, HYPHEN_REPLACEMENT)}_${prop}`, propValue]; }); - if (deploy.deployable.hostPortMapping && Object.keys(deploy.deployable.hostPortMapping).length > 0) { - Object.keys(deploy.deployable.hostPortMapping).forEach((key) => { - const propValue = deploy.active ? `${key}-${deploy.publicUrl}` : deploy.deployable.defaultPublicUrl; - serviceEnv.push([ - `${key}-${deploy.deployable.name.replace(/-/g, HYPHEN_REPLACEMENT)}_publicUrl`, - propValue, - ]); + if (deployable.hostPortMapping && Object.keys(deployable.hostPortMapping).length > 0) { + Object.keys(deployable.hostPortMapping).forEach((key) => { + const propValue = deploy.active ? `${key}-${deploy.publicUrl}` : deployable.defaultPublicUrl; + serviceEnv.push([`${key}-${deployable.name.replace(/-/g, HYPHEN_REPLACEMENT)}_publicUrl`, propValue]); }); } return { @@ -126,8 +135,13 @@ export abstract class EnvironmentVariables { return deploy.service?.type !== DeployTypes.CONFIGURATION; }) .reduce((env, deploy) => { - const serviceEnv: Array<[string, string]> = ALLOWED_PROPERTIES.map((prop) => { - let propValue = null; + const service = deploy.service; + if (service == null) { + return env; + } + + const serviceEnv: Array<[string, string | null | undefined]> = ALLOWED_PROPERTIES.map((prop) => { + let propValue: string | null | undefined = null; if (deploy.active) { propValue = deploy[prop]; if (prop === 'UUID') { @@ -135,22 +149,22 @@ export abstract class EnvironmentVariables { } } else { if (prop === 'UUID') { - propValue = deploy.service.defaultUUID; + propValue = service.defaultUUID; } else if (prop === 'publicUrl') { - propValue = deploy.service.defaultPublicUrl; + propValue = service.defaultPublicUrl; } else if (prop === 'internalHostname') { - propValue = deploy.service.defaultInternalHostname; + propValue = service.defaultInternalHostname; } else { propValue = ''; } } - return [`${deploy.service.name.replace(/-/g, HYPHEN_REPLACEMENT)}_${prop}`, propValue]; + return [`${service.name.replace(/-/g, HYPHEN_REPLACEMENT)}_${prop}`, propValue]; }); - if (deploy.service.hostPortMapping && Object.keys(deploy.service.hostPortMapping).length > 0) { - Object.keys(deploy.service.hostPortMapping).forEach((key) => { - const propValue = deploy.active ? `${key}-${deploy.publicUrl}` : deploy.service.defaultPublicUrl; - serviceEnv.push([`${key}-${deploy.service.name.replace(/-/g, HYPHEN_REPLACEMENT)}_publicUrl`, propValue]); + if (service.hostPortMapping && Object.keys(service.hostPortMapping).length > 0) { + Object.keys(service.hostPortMapping).forEach((key) => { + const propValue = deploy.active ? `${key}-${deploy.publicUrl}` : service.defaultPublicUrl; + serviceEnv.push([`${key}-${service.name.replace(/-/g, HYPHEN_REPLACEMENT)}_publicUrl`, propValue]); }); } return { @@ -180,7 +194,10 @@ export abstract class EnvironmentVariables { * @param build The LC build * @returns A dictionary of available environment variables key/value pair */ - async availableEnvironmentVariablesForBuild(build: Build): Promise> { + async availableEnvironmentVariablesForBuild( + build: Build, + options: EnvironmentVariableDictionaryOptions = {} + ): Promise> { let availableEnv: Record; if (build == null) { @@ -200,14 +217,21 @@ export abstract class EnvironmentVariables { ); } - availableEnv = await this.buildEnvironmentVariableDictionary(deploys, build.uuid, build.enableFullYaml, build, { - buildUUID: build.uuid, - buildSHA: build.sha, - pullRequestNumber: build.pullRequest?.pullRequestNumber, - namespace: build.namespace, - branchName: build.pullRequest?.branchName, - repoName: build.pullRequest?.fullName, - }); + availableEnv = await this.buildEnvironmentVariableDictionary( + deploys, + build.uuid, + build.enableFullYaml, + build, + { + buildUUID: build.uuid, + buildSHA: build.sha, + pullRequestNumber: build.pullRequest?.pullRequestNumber, + namespace: build.namespace, + branchName: build.pullRequest?.branchName, + repoName: build.pullRequest?.fullName, + }, + options + ); return availableEnv; } @@ -222,7 +246,7 @@ export abstract class EnvironmentVariables { fullYamlSupport: boolean ): Promise>> { const configurationDeploys = deploys.filter((deploy) => { - const serviceType: DeployTypes = fullYamlSupport ? deploy.deployable?.type : deploy.service?.type; + const serviceType = fullYamlSupport ? deploy.deployable?.type : deploy.service?.type; return serviceType === DeployTypes.CONFIGURATION; }); diff --git a/src/server/lib/tests/buildEnvVariables.test.ts b/src/server/lib/tests/buildEnvVariables.test.ts index 61cee986..7d2afd70 100644 --- a/src/server/lib/tests/buildEnvVariables.test.ts +++ b/src/server/lib/tests/buildEnvVariables.test.ts @@ -19,29 +19,37 @@ mockRedisClient(); import Database from 'server/database'; import * as models from 'server/models'; -import { DeployTypes } from 'shared/constants'; +import { DeployTypes, FeatureFlags, NO_DEFAULT_ENV_UUID } from 'shared/constants'; import { QueryBuilder } from 'objection'; import { BuildEnvironmentVariables } from 'server/lib/buildEnvVariables'; jest.mock('server/database'); -jest.mock('server/services/globalConfig'); - -import GlobalConfigService from 'server/services/globalConfig'; -import { IServices } from 'server/services/types'; - -const mockedGetAllConfigs = jest.fn().mockResolvedValue({ +const mockGetAllConfigs = jest.fn().mockResolvedValue({ lifecycleDefaults: { defaultUUID: 'mockedUUID', defaultPublicUrl: 'mockedPublicUrl', }, }); -const mockedInstance = { - getAllConfigs: mockedGetAllConfigs, -}; +jest.mock('server/services/globalConfig', () => ({ + __esModule: true, + default: { + getInstance: jest.fn(() => ({ + getAllConfigs: mockGetAllConfigs, + })), + }, +})); + +jest.mock('server/models/yaml', () => ({ + fetchLifecycleConfigByRepository: jest.fn(), + getDeployingServicesByName: jest.fn(), + getEnvironmentVariables: jest.fn(), + getInitEnvironmentVariables: jest.fn(), +})); -(GlobalConfigService.getInstance as jest.Mock).mockReturnValue(mockedInstance); +import GlobalConfigService from 'server/services/globalConfig'; +import { IServices } from 'server/services/types'; function createTestingDeploy( service: { @@ -67,11 +75,11 @@ function createTestingDeploy( }, active: boolean, properties: { - branchName: string; - ipAddress: string; - publicUrl: string; - internalHostname: string; - dockerImage: string; + branchName: string | null; + ipAddress: string | null; + publicUrl: string | null; + internalHostname: string | null; + dockerImage: string | null; sha: string; } ): models.Deploy { @@ -81,30 +89,29 @@ function createTestingDeploy( deploy.serviceId = 100; deploy.service.name = service.name; deploy.service.type = service.type; - deploy.service.defaultUUID = service.defaultUUID; - deploy.service.port = service.port; + deploy.service.defaultUUID = service.defaultUUID ?? ''; + deploy.service.port = service.port ?? ''; deploy.active = active; - deploy.deployableId = null; deploy.deployable = new models.Deployable(); deploy.deployableId = 23000; deploy.deployable.name = deployable.name; deploy.deployable.type = deployable.type; - deploy.deployable.defaultUUID = deployable.defaultUUID; - deploy.deployable.port = deployable.port; + deploy.deployable.defaultUUID = deployable.defaultUUID ?? ''; + deploy.deployable.port = deployable.port ?? ''; if (active) { - deploy.branchName = properties.branchName; - deploy.ipAddress = properties.ipAddress; - deploy.publicUrl = properties.publicUrl; - deploy.internalHostname = properties.internalHostname; - deploy.dockerImage = properties.dockerImage; + deploy.branchName = properties.branchName as string; + deploy.ipAddress = properties.ipAddress as string; + deploy.publicUrl = properties.publicUrl as string; + deploy.internalHostname = properties.internalHostname as string; + deploy.dockerImage = properties.dockerImage as string; deploy.sha = properties.sha; deploy.build = new models.Build(); deploy.build.uuid = build.uuid; } else { - deploy.service.defaultPublicUrl = properties.publicUrl; - deploy.service.defaultInternalHostname = properties.internalHostname; + deploy.service.defaultPublicUrl = properties.publicUrl as string; + deploy.service.defaultInternalHostname = properties.internalHostname as string; } return deploy; @@ -537,6 +544,46 @@ describe('EnvironmentVariables', () => { repoName: 'myorg/myrepo', }); }); + + test('can skip no-default env resolve when building non-env-template contexts', async () => { + const inactiveDeploy = new models.Deploy(); + inactiveDeploy.active = false; + inactiveDeploy.deployable = new models.Deployable(); + inactiveDeploy.deployable.name = 'inactive-web'; + inactiveDeploy.deployable.type = DeployTypes.GITHUB; + inactiveDeploy.deployable.defaultUUID = 'dev-0'; + inactiveDeploy.deployable.defaultPublicUrl = 'inactive-web-dev-0.lifecycle.dev.example.com'; + inactiveDeploy.deployable.defaultInternalHostname = 'inactive-web-dev-0'; + + const buildWithNoDefaultResolve = new models.Build(); + buildWithNoDefaultResolve.uuid = 'mock-test-12345'; + buildWithNoDefaultResolve.enableFullYaml = true; + buildWithNoDefaultResolve.enabledFeatures = [FeatureFlags.NO_DEFAULT_ENV_RESOLVE]; + + await expect( + envVariables.buildEnvironmentVariableDictionary( + [inactiveDeploy], + buildWithNoDefaultResolve.uuid, + true, + buildWithNoDefaultResolve + ) + ).resolves.toMatchObject({ + inactive______web_internalHostname: NO_DEFAULT_ENV_UUID, + }); + + await expect( + envVariables.buildEnvironmentVariableDictionary( + [inactiveDeploy], + buildWithNoDefaultResolve.uuid, + true, + buildWithNoDefaultResolve, + undefined, + { applyNoDefaultEnvResolveFeatureFlag: false } + ) + ).resolves.toMatchObject({ + inactive______web_internalHostname: 'inactive-web-dev-0', + }); + }); }); describe('configurationServiceEnvironments', () => { diff --git a/src/server/models/Build.ts b/src/server/models/Build.ts index 4ed6370a..62ee0f02 100644 --- a/src/server/models/Build.ts +++ b/src/server/models/Build.ts @@ -58,8 +58,6 @@ export default class Build extends Model { enableFullYaml: boolean; webhooksYaml: string; - dashboardLinks: Record; - enabledFeatures: string[]; isStatic: boolean; githubDeployments: boolean; diff --git a/src/server/services/__tests__/build.test.ts b/src/server/services/__tests__/build.test.ts index 3b9ade3d..74abe87c 100644 --- a/src/server/services/__tests__/build.test.ts +++ b/src/server/services/__tests__/build.test.ts @@ -52,6 +52,20 @@ jest.mock('server/lib/logger', () => ({ LogStage: {}, })); +jest.mock('shared/config', () => ({ + TMP_PATH: '/tmp', + QUEUE_NAMES: { + BUILD_QUEUE: 'build', + RESOLVE_DEPLOY_BUILD_QUEUE: 'resolve-deploy', + BUILD_CLEANUP_QUEUE: 'build-cleanup', + BUILD_REQUEST_QUEUE: 'build-request', + GLOBAL_CONFIG_CACHE_REFRESH: 'global-config-refresh', + GITHUB_CLIENT_TOKEN_CACHE_REFRESH: 'github-client-token-refresh', + INGRESS_MANIFEST_QUEUE: 'ingress-manifest', + AGENT_PREWARM_QUEUE: 'agent-prewarm', + }, +})); + jest.mock('server/models', () => ({ Build: class {}, Deploy: { @@ -70,6 +84,36 @@ jest.mock('server/lib/kubernetes', () => ({ createOrUpdateServiceAccount: jest.fn(), })); +jest.mock('server/lib/github', () => ({ + createGitDeployment: jest.fn(), + updateGitDeploymentStatus: jest.fn(), + getPullRequest: jest.fn(), +})); + +jest.mock('server/lib/helm', () => ({ + uninstallHelmReleases: jest.fn(), +})); + +jest.mock('server/lib/helm/utils', () => ({ + ingressBannerSnippet: jest.fn(() => ''), +})); + +jest.mock('server/lib/buildEnvVariables', () => ({ + BuildEnvironmentVariables: jest.fn().mockImplementation(() => ({ + resolve: jest.fn().mockResolvedValue({}), + })), +})); + +jest.mock('server/services/deploy', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({})), +})); + +jest.mock('server/services/webhook', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({})), +})); + jest.mock('server/services/globalConfig', () => ({ __esModule: true, default: { @@ -86,7 +130,7 @@ jest.mock('server/lib/fastly', () => ); import BuildService from '../build'; -import { DeployStatus, DeployTypes } from 'shared/constants'; +import { BuildKind, BuildStatus, DeployStatus, DeployTypes } from 'shared/constants'; describe('BuildService failure boundaries', () => { let buildService: BuildService; @@ -170,6 +214,50 @@ describe('BuildService failure boundaries', () => { }); }); +describe('BuildService status updates', () => { + test('updates only build status fields', async () => { + const patch = jest.fn().mockResolvedValue(undefined); + const buildService = new BuildService( + { + services: { + Webhook: { + webhookQueue: { + add: jest.fn(), + }, + }, + }, + } as any, + {} as any, + {} as any, + { + registerQueue: jest.fn(() => ({ + add: mockQueueAdd, + process: jest.fn(), + on: jest.fn(), + })), + } as any + ); + const build = { + id: 1, + uuid: 'sample-build', + runUUID: 'run-1', + kind: BuildKind.SANDBOX, + deploys: [], + reload: jest.fn().mockResolvedValue(undefined), + $fetchGraph: jest.fn().mockResolvedValue(undefined), + $query: jest.fn(() => ({ patch })), + }; + + await buildService.updateStatusAndComment(build as any, BuildStatus.DEPLOYED, 'run-1', true, true); + + expect(patch).toHaveBeenCalledTimes(1); + expect(patch).toHaveBeenCalledWith({ + status: BuildStatus.DEPLOYED, + statusMessage: '', + }); + }); +}); + describe('BuildService queue fingerprinting', () => { let buildService: BuildService; let mockBuildQuery: any; diff --git a/src/server/services/__tests__/buildMetadata.test.ts b/src/server/services/__tests__/buildMetadata.test.ts new file mode 100644 index 00000000..9de2afdd --- /dev/null +++ b/src/server/services/__tests__/buildMetadata.test.ts @@ -0,0 +1,219 @@ +/** + * Copyright 2026 Lifecycle contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const mockGetConfig = jest.fn(); +const mockSetConfig = jest.fn(); +const mockAvailableEnvironmentVariablesForBuild = jest.fn(); +const mockBuildQuery = jest.fn(); + +jest.mock('server/lib/dependencies', () => ({ + defaultDb: {}, + defaultRedis: {}, + defaultRedlock: {}, + defaultQueueManager: {}, +})); + +jest.mock('nanoid', () => ({ + nanoid: jest.fn(() => 'generated1'), +})); + +jest.mock('server/services/globalConfig', () => ({ + __esModule: true, + default: { + getInstance: jest.fn(() => ({ + getConfig: (...args: unknown[]) => mockGetConfig(...args), + setConfig: (...args: unknown[]) => mockSetConfig(...args), + })), + }, +})); + +jest.mock('server/lib/buildEnvVariables', () => ({ + BuildEnvironmentVariables: jest.fn().mockImplementation(() => ({ + availableEnvironmentVariablesForBuild: (...args: unknown[]) => mockAvailableEnvironmentVariablesForBuild(...args), + })), +})); + +jest.mock('server/lib/logger', () => ({ + getLogger: jest.fn(() => ({ + error: jest.fn(), + })), +})); + +import BuildMetadataService, { BuildMetadataError } from '../buildMetadata'; + +function createService() { + return new BuildMetadataService( + { + models: { + Build: { + query: mockBuildQuery, + }, + }, + } as any, + {} as any, + {} as any, + {} as any + ); +} + +describe('BuildMetadataService', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetConfig.mockResolvedValue({ links: [] }); + mockSetConfig.mockResolvedValue(undefined); + mockAvailableEnvironmentVariablesForBuild.mockResolvedValue({ + buildUUID: 'sample-build', + branchName: 'feature/add-metadata', + namespace: 'env-sample-build', + web_publicUrl: 'web-sample-build.example.com', + }); + }); + + test('returns sorted metadata links from global config', async () => { + mockGetConfig.mockResolvedValueOnce({ + links: [ + { id: 'two', text: 'Two', icon: 'route', link: 'https://example.com/two', position: 2 }, + { id: 'one', text: 'One', icon: 'file', link: 'https://example.com/one', position: 1 }, + ], + }); + + await expect(createService().getConfig()).resolves.toEqual({ + links: [ + { id: 'one', text: 'One', icon: 'file', link: 'https://example.com/one', position: 1 }, + { id: 'two', text: 'Two', icon: 'route', link: 'https://example.com/two', position: 2 }, + ], + }); + }); + + test('creates metadata links with generated IDs and append position', async () => { + mockGetConfig.mockResolvedValueOnce({ + links: [{ id: 'existing', text: 'Existing', icon: 'file', link: 'https://example.com/old', position: 4 }], + }); + + const metadata = await createService().createLink({ + text: 'New link', + icon: 'container', + link: 'https://example.com/new?build={{{buildUUID}}}', + }); + + expect(metadata.links).toEqual([ + { id: 'existing', text: 'Existing', icon: 'file', link: 'https://example.com/old', position: 4 }, + { + id: 'metadata-link-generated1', + text: 'New link', + icon: 'container', + link: 'https://example.com/new?build={{{buildUUID}}}', + position: 5, + }, + ]); + expect(mockSetConfig).toHaveBeenCalledWith('metadata', metadata); + }); + + test('patches metadata links without replacing unspecified fields', async () => { + mockGetConfig.mockResolvedValueOnce({ + links: [{ id: 'logs', text: 'Logs', icon: 'file', link: 'https://example.com/logs', position: 1 }], + }); + + const metadata = await createService().updateLink('logs', { text: 'Runtime logs', position: 0 }); + + expect(metadata.links).toEqual([ + { id: 'logs', text: 'Runtime logs', icon: 'file', link: 'https://example.com/logs', position: 0 }, + ]); + }); + + test('deletes metadata links', async () => { + mockGetConfig.mockResolvedValueOnce({ + links: [ + { id: 'logs', text: 'Logs', icon: 'file', link: 'https://example.com/logs', position: 1 }, + { id: 'traces', text: 'Traces', icon: 'route', link: 'https://example.com/traces', position: 2 }, + ], + }); + + await createService().deleteLink('logs'); + + expect(mockSetConfig).toHaveBeenCalledWith('metadata', { + links: [{ id: 'traces', text: 'Traces', icon: 'route', link: 'https://example.com/traces', position: 2 }], + }); + }); + + test('rejects unsafe link schemes on write', async () => { + await expect( + createService().createLink({ + text: 'Unsafe', + icon: 'alert', + link: 'javascript:alert(1)', + }) + ).rejects.toMatchObject({ + code: 'invalid_input', + message: 'Unsupported metadata link scheme: javascript:', + }); + }); + + test('renders metadata links with the build environment variable context', async () => { + mockGetConfig.mockResolvedValueOnce({ + links: [ + { + id: 'logs', + text: 'Logs', + icon: 'file', + link: 'https://example.com/logs?build={{buildUUID}}&branch={{branchName}}&service={{{web_publicUrl}}}&missing={{missingValue}}', + position: 1, + }, + ], + }); + + const metadata = await createService().renderMetadataForBuild({ uuid: 'sample-build' } as any); + + expect(mockAvailableEnvironmentVariablesForBuild).toHaveBeenCalledWith( + { uuid: 'sample-build' }, + { applyNoDefaultEnvResolveFeatureFlag: false } + ); + expect(metadata.links).toEqual([ + { + id: 'logs', + text: 'Logs', + icon: 'file', + link: 'https://example.com/logs?build=sample-build&branch=feature/add-metadata&service=web-sample-build.example.com&missing=', + position: 1, + }, + ]); + }); + + test('rejects unsafe rendered link schemes', async () => { + mockGetConfig.mockResolvedValueOnce({ + links: [{ id: 'dynamic', text: 'Dynamic', icon: 'alert', link: '{{scheme}}:alert(1)', position: 1 }], + }); + mockAvailableEnvironmentVariablesForBuild.mockResolvedValueOnce({ scheme: 'javascript' }); + + await expect(createService().renderMetadataForBuild({ uuid: 'sample-build' } as any)).rejects.toBeInstanceOf( + BuildMetadataError + ); + }); + + test('renders metadata by build UUID and returns not found for missing builds', async () => { + const findOne = jest.fn().mockReturnThis(); + const select = jest.fn().mockResolvedValue({ uuid: 'sample-build' }); + mockBuildQuery.mockReturnValue({ findOne, select }); + + await expect(createService().renderMetadataForBuildUUID('sample-build')).resolves.toEqual({ links: [] }); + expect(findOne).toHaveBeenCalledWith({ uuid: 'sample-build' }); + + select.mockResolvedValueOnce(undefined); + await expect(createService().renderMetadataForBuildUUID('missing-build')).rejects.toMatchObject({ + code: 'not_found', + }); + }); +}); diff --git a/src/server/services/activityStream.ts b/src/server/services/activityStream.ts index bdeaf009..794e25b6 100644 --- a/src/server/services/activityStream.ts +++ b/src/server/services/activityStream.ts @@ -49,6 +49,7 @@ import { nanoid } from 'nanoid'; import { redisClient } from 'server/lib/dependencies'; import GlobalConfigService from './globalConfig'; import { ChartType, determineChartType } from 'server/lib/nativeHelm'; +import BuildMetadataService from './buildMetadata'; const createDeployMessage = async () => { const deployLabel = await getDeployLabel(); @@ -810,7 +811,7 @@ export default class ActivityStream extends BaseService { getLogger().error({ error: e }, `Comment: env block generation failed fullYaml=${build.enableFullYaml}`); return ''; }); - message += await this.dashboardBlock(build, deploys).catch((e) => { + message += await this.dashboardBlock(build).catch((e) => { getLogger().error({ error: e }, `Comment: dashboard generation failed fullYaml=${build.enableFullYaml}`); return ''; }); @@ -839,7 +840,7 @@ export default class ActivityStream extends BaseService { getLogger().error({ error: e }, `Comment: env block generation failed fullYaml=${build.enableFullYaml}`); return ''; }); - message += await this.dashboardBlock(build, deploys).catch((e) => { + message += await this.dashboardBlock(build).catch((e) => { getLogger().error({ error: e }, `Comment: dashboard generation failed fullYaml=${build.enableFullYaml}`); return ''; }); @@ -858,7 +859,7 @@ export default class ActivityStream extends BaseService { getLogger().error({ error: e }, `Comment: env block generation failed fullYaml=${build.enableFullYaml}`); return ''; }); - message += await this.dashboardBlock(build, deploys).catch((e) => { + message += await this.dashboardBlock(build).catch((e) => { getLogger().error({ error: e }, `Comment: dashboard generation failed fullYaml=${build.enableFullYaml}`); return ''; }); @@ -994,46 +995,10 @@ export default class ActivityStream extends BaseService { return message + '\n'; } - private async dashboardBlock(build: Build, deploys: Deploy[]) { - const datadogLogFastlyUrl = new URL('https://app.datadoghq.com/logs'); - const datadogLogUrl = new URL('https://app.datadoghq.com/logs'); - const datadogServerlessUrl = new URL('https://app.datadoghq.com/functions'); - const datadogTraceUrl = new URL('https://app.datadoghq.com/apm/traces'); - const datadogRumSessionsUrl = new URL('https://app.datadoghq.com/rum/explorer'); - const datadogContainersUrl = new URL('https://app.datadoghq.com/containers'); - - datadogLogFastlyUrl.searchParams.append('query', `source:fastly @request.host:*${build.uuid}*`); - datadogLogFastlyUrl.searchParams.append('paused', 'false'); - datadogLogUrl.searchParams.append('query', `env:lifecycle-${build.uuid}`); - datadogLogUrl.searchParams.append('paused', 'false'); - datadogServerlessUrl.searchParams.append('text_search', `env:*${build.uuid}*`); - datadogServerlessUrl.searchParams.append('paused', 'false'); - datadogTraceUrl.searchParams.append('query', `env:*${build.uuid}*`); - datadogTraceUrl.searchParams.append('paused', 'false'); - datadogRumSessionsUrl.searchParams.append('query', `env:*${build.uuid}*`); - datadogRumSessionsUrl.searchParams.append('live', 'true'); - datadogContainersUrl.searchParams.append('query', `env:lifecycle-${build.uuid}`); - datadogContainersUrl.searchParams.append('paused', 'false'); - - let message = '
\n'; - message += 'Dashboards\n\n'; - message += '|| Links |\n'; - message += '| ------------- | ------------- |\n'; - message += `| Fastly Logs | ${datadogLogFastlyUrl.href} |\n`; - message += `| Containers | ${datadogContainersUrl.href} |\n`; - message += `| Lifecycle Env Logs | ${datadogLogUrl.href} |\n`; - message += `| Tracing | ${datadogTraceUrl.href} |\n`; - message += `| Serverless | ${datadogServerlessUrl.href} |\n`; - message += `| RUM (If Enabled) | ${datadogRumSessionsUrl.href} |\n`; - if (await this.containsFastlyDeployment(deploys)) { - const fastlyServiceDashboardUrl: URL = await this.fastly.getServiceDashboardUrl(build.uuid, 'fastly'); - if (fastlyServiceDashboardUrl) { - message += `| Fastly Dashboard | ${fastlyServiceDashboardUrl.href} |\n`; - } - } - message += '
\n'; - - return message; + private async dashboardBlock(build: Build) { + return new BuildMetadataService(this.db, this.redis, this.redlock, this.queueManager).renderDashboardMarkdown( + build + ); } private async purgeFastlyServiceCache(uuid: string) { diff --git a/src/server/services/agentSandboxSession.ts b/src/server/services/agentSandboxSession.ts index 7b4ac3a4..50e38a04 100644 --- a/src/server/services/agentSandboxSession.ts +++ b/src/server/services/agentSandboxSession.ts @@ -546,7 +546,6 @@ export default class AgentSandboxSessionService extends BaseService { status: BuildStatus.QUEUED, githubDeployments: false, isStatic: false, - dashboardLinks: {}, } as unknown as Partial); const deployableIdByBaseId = new Map(); diff --git a/src/server/services/build.ts b/src/server/services/build.ts index 59d08efd..5fb3b4e2 100644 --- a/src/server/services/build.ts +++ b/src/server/services/build.ts @@ -36,8 +36,6 @@ import { withLogContext, getLogger, extractContextForQueue, LogStage, updateLogC import { ParsingError, YamlConfigParser } from 'server/lib/yamlConfigParser'; import { ValidationError, YamlConfigValidator } from 'server/lib/yamlConfigValidator'; -import Fastly from 'server/lib/fastly'; -import { constructBuildLinks, determineIfFastlyIsUsed, insertBuildLink } from 'shared/utils'; import { type LifecycleYamlConfigOptions } from 'server/models/yaml/types'; import { DeploymentManager } from 'server/lib/deploymentManager/deploymentManager'; import { Tracer } from 'server/lib/tracer'; @@ -66,7 +64,6 @@ export interface IngressConfiguration { } export default class BuildService extends BaseService { - fastly = new Fastly(this.redis); ingressService = new IngressService(this.db, this.redis, this.redlock, this.queueManager); /** * For every build that is not closed @@ -1149,21 +1146,6 @@ export default class BuildService extends BaseService { statusMessage, }); - // add dashboard links to build database - let dashboardLinks = constructBuildLinks(build.uuid); - const hasFastly = determineIfFastlyIsUsed(deploys); - if (hasFastly) { - try { - const fastlyDashboardUrl = await this.fastly.getServiceDashboardUrl(build.uuid, 'fastly'); - if (fastlyDashboardUrl) { - dashboardLinks = insertBuildLink(dashboardLinks, 'Fastly Dashboard', fastlyDashboardUrl.href); - } - } catch (err) { - getLogger().error({ error: err }, 'Fastly: dashboard URL fetch failed'); - } - } - await build.$query().patch({ dashboardLinks }); - if (!isSandboxBuild && pullRequest && repository) { await this.db.services.ActivityStream.updatePullRequestActivityStream( build, diff --git a/src/server/services/buildMetadata.ts b/src/server/services/buildMetadata.ts new file mode 100644 index 00000000..b972e461 --- /dev/null +++ b/src/server/services/buildMetadata.ts @@ -0,0 +1,324 @@ +/** + * Copyright 2026 Lifecycle contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as mustache from 'mustache'; +import { nanoid } from 'nanoid'; +import { BuildEnvironmentVariables } from 'server/lib/buildEnvVariables'; +import { getLogger } from 'server/lib/logger'; +import { Build } from 'server/models'; +import BaseService from './_service'; +import GlobalConfigService from './globalConfig'; +import type { BuildMetadataConfig, BuildMetadataLink } from './types/globalConfig'; + +export const BUILD_METADATA_CONFIG_KEY = 'metadata'; +export type { BuildMetadataConfig, BuildMetadataLink } from './types/globalConfig'; + +const UNSAFE_URL_PROTOCOLS = new Set(['javascript:', 'data:']); +const LINK_ID_PREFIX = 'metadata-link'; + +export class BuildMetadataError extends Error { + constructor(message: string, public code: 'invalid_input' | 'not_found' | 'invalid_rendered_link') { + super(message); + this.name = 'BuildMetadataError'; + } +} + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function readStringField(value: unknown, fieldName: string): string { + if (typeof value !== 'string') { + throw new BuildMetadataError(`${fieldName} must be a string.`, 'invalid_input'); + } + + const trimmed = value.trim(); + if (!trimmed) { + throw new BuildMetadataError(`${fieldName} must not be empty.`, 'invalid_input'); + } + + return trimmed; +} + +function readOptionalStringField(value: unknown, fieldName: string): string | undefined { + if (value === undefined) { + return undefined; + } + + return readStringField(value, fieldName); +} + +function readPosition(value: unknown, fallback: number): number { + if (value === undefined) { + return fallback; + } + + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new BuildMetadataError('position must be an integer.', 'invalid_input'); + } + + return value; +} + +function assertSafeUrlScheme(link: string): void { + const protocolMatch = link.trim().match(/^([a-z][a-z0-9+.-]*):/i); + const protocol = protocolMatch?.[1]?.toLowerCase(); + if (protocol && UNSAFE_URL_PROTOCOLS.has(`${protocol}:`)) { + throw new BuildMetadataError(`Unsupported metadata link scheme: ${protocol}:`, 'invalid_input'); + } +} + +function validateRenderedUrl(link: string, id: string): string { + const trimmed = link.trim(); + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new BuildMetadataError(`Rendered metadata link '${id}' must be a valid URL.`, 'invalid_rendered_link'); + } + + if (UNSAFE_URL_PROTOCOLS.has(parsed.protocol.toLowerCase())) { + throw new BuildMetadataError( + `Rendered metadata link '${id}' uses an unsupported URL scheme.`, + 'invalid_rendered_link' + ); + } + + return trimmed; +} + +function renderLinkTemplate(template: string, context: Record): string { + const unescapedTemplate = template.replace(/{{{?([^{}]*?)}}}?/g, '{{{$1}}}'); + return mustache.render(unescapedTemplate, context); +} + +function markdownTableCell(value: string): string { + return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +} + +export default class BuildMetadataService extends BaseService { + private globalConfig = GlobalConfigService.getInstance(); + + private sortLinks(links: BuildMetadataLink[]): BuildMetadataLink[] { + return [...links].sort((left, right) => left.position - right.position || left.text.localeCompare(right.text)); + } + + private normalizeConfig(value: unknown): BuildMetadataConfig { + if (!isObject(value) || !Array.isArray(value.links)) { + return { links: [] }; + } + + const links = value.links.flatMap((link): BuildMetadataLink[] => { + if (!isObject(link)) { + return []; + } + + const { id, text, icon, link: href, position } = link; + if ( + typeof id !== 'string' || + typeof text !== 'string' || + typeof icon !== 'string' || + typeof href !== 'string' || + typeof position !== 'number' || + !Number.isInteger(position) + ) { + return []; + } + + return [{ id, text, icon, link: href, position }]; + }); + + return { links: this.sortLinks(links) }; + } + + private async loadConfig(): Promise { + return this.normalizeConfig(await this.globalConfig.getConfig(BUILD_METADATA_CONFIG_KEY)); + } + + private async saveConfig(config: BuildMetadataConfig): Promise { + const normalized = this.normalizeConfig(config); + await this.globalConfig.setConfig(BUILD_METADATA_CONFIG_KEY, normalized); + return normalized; + } + + private nextPosition(links: BuildMetadataLink[]): number { + if (links.length === 0) { + return 0; + } + + return Math.max(...links.map((link) => link.position)) + 1; + } + + private validateAllowedFields(input: Record, allowedFields: Set): void { + const unsupportedFields = Object.keys(input).filter((field) => !allowedFields.has(field)); + if (unsupportedFields.length > 0) { + throw new BuildMetadataError( + `Unsupported metadata link fields: ${unsupportedFields.join(', ')}`, + 'invalid_input' + ); + } + } + + private readCreateInput(input: unknown, position: number): Omit { + if (!isObject(input)) { + throw new BuildMetadataError('Request body must be an object.', 'invalid_input'); + } + + this.validateAllowedFields(input, new Set(['text', 'icon', 'link', 'position'])); + + const link = readStringField(input.link, 'link'); + assertSafeUrlScheme(link); + + return { + text: readStringField(input.text, 'text'), + icon: readStringField(input.icon, 'icon'), + link, + position: readPosition(input.position, position), + }; + } + + private readPatchInput(input: unknown): Partial> { + if (!isObject(input)) { + throw new BuildMetadataError('Request body must be an object.', 'invalid_input'); + } + + this.validateAllowedFields(input, new Set(['text', 'icon', 'link', 'position'])); + + const patch: Partial> = {}; + const text = readOptionalStringField(input.text, 'text'); + const icon = readOptionalStringField(input.icon, 'icon'); + const link = readOptionalStringField(input.link, 'link'); + + if (text !== undefined) { + patch.text = text; + } + if (icon !== undefined) { + patch.icon = icon; + } + if (link !== undefined) { + assertSafeUrlScheme(link); + patch.link = link; + } + if (input.position !== undefined) { + patch.position = readPosition(input.position, 0); + } + + if (Object.keys(patch).length === 0) { + throw new BuildMetadataError('Request body must include at least one supported field.', 'invalid_input'); + } + + return patch; + } + + async getConfig(): Promise { + return this.loadConfig(); + } + + async createLink(input: unknown): Promise { + const config = await this.loadConfig(); + const link = this.readCreateInput(input, this.nextPosition(config.links)); + + return this.saveConfig({ + links: [ + ...config.links, + { + id: `${LINK_ID_PREFIX}-${nanoid(10)}`, + ...link, + }, + ], + }); + } + + async updateLink(id: string, input: unknown): Promise { + const config = await this.loadConfig(); + const linkIndex = config.links.findIndex((link) => link.id === id); + if (linkIndex === -1) { + throw new BuildMetadataError(`Metadata link '${id}' not found.`, 'not_found'); + } + + const patch = this.readPatchInput(input); + const links = [...config.links]; + links[linkIndex] = { ...links[linkIndex], ...patch }; + + return this.saveConfig({ links }); + } + + async deleteLink(id: string): Promise { + const config = await this.loadConfig(); + const links = config.links.filter((link) => link.id !== id); + if (links.length === config.links.length) { + throw new BuildMetadataError(`Metadata link '${id}' not found.`, 'not_found'); + } + + await this.saveConfig({ links }); + } + + async renderMetadataForBuild(build: Build): Promise { + const config = await this.loadConfig(); + if (config.links.length === 0) { + return { links: [] }; + } + + const context = await new BuildEnvironmentVariables(this.db).availableEnvironmentVariablesForBuild(build, { + applyNoDefaultEnvResolveFeatureFlag: false, + }); + + return { + links: this.sortLinks(config.links).map((link) => { + const renderedLink = renderLinkTemplate(link.link, context); + return { + ...link, + link: validateRenderedUrl(renderedLink, link.id), + }; + }), + }; + } + + async renderMetadataForBuildUUID(uuid: string): Promise { + const build = await this.db.models.Build.query() + .findOne({ uuid }) + .select('id', 'uuid', 'sha', 'namespace', 'enableFullYaml', 'runUUID'); + + if (!build) { + throw new BuildMetadataError(`Build with UUID ${uuid} not found.`, 'not_found'); + } + + return this.renderMetadataForBuild(build); + } + + async renderDashboardMarkdown(build: Build): Promise { + const metadata = await this.renderMetadataForBuild(build); + if (metadata.links.length === 0) { + return ''; + } + + let message = '
\n'; + message += 'Dashboards\n\n'; + message += '|| Links |\n'; + message += '| ------------- | ------------- |\n'; + metadata.links.forEach((link) => { + message += `| ${markdownTableCell(link.text)} | ${markdownTableCell(link.link)} |\n`; + }); + message += '
\n'; + + return message; + } + + logRenderFailure(error: unknown): void { + getLogger().error({ error }, 'Metadata: render failed'); + } +} diff --git a/src/server/services/types/globalConfig.ts b/src/server/services/types/globalConfig.ts index fc7b97b0..cb18fbff 100644 --- a/src/server/services/types/globalConfig.ts +++ b/src/server/services/types/globalConfig.ts @@ -41,6 +41,19 @@ export type GlobalConfig = { ttl_cleanup?: TTLCleanupConfig; secretProviders?: SecretProvidersConfig; logArchival?: LogArchivalConfig; + metadata?: BuildMetadataConfig; +}; + +export type BuildMetadataLink = { + id: string; + text: string; + icon: string; + link: string; + position: number; +}; + +export type BuildMetadataConfig = { + links: BuildMetadataLink[]; }; export type AppSetup = { diff --git a/src/shared/openApiSpec.test.ts b/src/shared/openApiSpec.test.ts index 11d90929..9ca6ea59 100644 --- a/src/shared/openApiSpec.test.ts +++ b/src/shared/openApiSpec.test.ts @@ -13,6 +13,18 @@ function getOperation(path: string, method: string) { } describe('OpenAPI v2 agent session contract', () => { + it('documents build metadata routes and link schemas', () => { + expect(getOperation('/api/v2/builds/{uuid}/metadata', 'get')?.tags).toEqual(['Builds']); + expect(getOperation('/api/v2/config/metadata', 'get')?.tags).toEqual(['Config']); + expect(getOperation('/api/v2/config/metadata', 'post')?.tags).toEqual(['Config']); + expect(getOperation('/api/v2/config/metadata/{id}', 'patch')?.tags).toEqual(['Config']); + expect(getOperation('/api/v2/config/metadata/{id}', 'delete')?.tags).toEqual(['Config']); + expect(schemas.BuildMetadata.required).toEqual(['links']); + expect(schemas.BuildMetadataLink.required).toEqual(['id', 'text', 'icon', 'link', 'position']); + expect(schemas.BuildMetadataLinkCreateRequest.additionalProperties).toBe(false); + expect(schemas.BuildMetadataLinkPatchRequest.additionalProperties).toBe(false); + }); + it('documents canonical run events with public context and a version', () => { const eventSchema = schemas.AgentRunMessagePartEvent; diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index f18735b7..f2f167ce 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -3007,6 +3007,65 @@ export const openApiSpecificationForV2Api: OAS3Options = { ], }, + BuildMetadataLink: { + type: 'object', + properties: { + id: { type: 'string', example: 'example-environment-logs' }, + text: { type: 'string', example: 'Environment logs' }, + icon: { type: 'string', example: 'FileCog' }, + link: { type: 'string', example: 'https://example.com/logs?build={{{buildUUID}}}' }, + position: { type: 'integer', example: 0 }, + }, + required: ['id', 'text', 'icon', 'link', 'position'], + }, + + BuildMetadata: { + type: 'object', + properties: { + links: { + type: 'array', + items: { $ref: '#/components/schemas/BuildMetadataLink' }, + }, + }, + required: ['links'], + }, + + BuildMetadataLinkCreateRequest: { + type: 'object', + properties: { + text: { type: 'string', example: 'Environment logs' }, + icon: { type: 'string', example: 'FileCog' }, + link: { type: 'string', example: 'https://example.com/logs?build={{{buildUUID}}}' }, + position: { type: 'integer', example: 0 }, + }, + required: ['text', 'icon', 'link'], + additionalProperties: false, + }, + + BuildMetadataLinkPatchRequest: { + type: 'object', + properties: { + text: { type: 'string', example: 'Environment logs' }, + icon: { type: 'string', example: 'FileCog' }, + link: { type: 'string', example: 'https://example.com/logs?build={{{buildUUID}}}' }, + position: { type: 'integer', example: 0 }, + }, + additionalProperties: false, + }, + + BuildMetadataSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { $ref: '#/components/schemas/BuildMetadata' }, + }, + required: ['data'], + }, + ], + }, + /** * @description The Deployable associated with a Deploy. */ diff --git a/src/shared/utils.test.ts b/src/shared/utils.test.ts index a6a39d1f..fc87dac7 100644 --- a/src/shared/utils.test.ts +++ b/src/shared/utils.test.ts @@ -20,17 +20,21 @@ import { determineFeatureFlagStatus, determineIfFastlyIsUsed, enableService, - processLinks, constructLinkDictionary, constructLinkRow, constructLinkTable, constructFastlyBuildLink, - constructBuildLinks, insertBuildLink, mergeKeyValueArrays, extractEnvVarsWithBuildDependencies, } from 'shared/utils'; +jest.mock('server/lib/logger', () => ({ + getLogger: jest.fn(() => ({ + error: jest.fn(), + })), +})); + import { FEATURE_FLAG, MALFORMED_FEATURE_FLAG_BOOLEAN_STRING } from 'shared/__fixtures__/utils'; describe('utils', () => { @@ -59,17 +63,6 @@ describe('utils', () => { }); }); - describe('#processLinks', () => { - test('returns empty array if buildId is empty', () => { - expect(processLinks()).toEqual([]); - }); - - test('returns array of links if buildId is provided', () => { - const links = processLinks('abc123'); - expect(links).toHaveLength(6); - }); - }); - describe('#constructLinkDictionary', () => { test('returns empty object if no links provided', () => { expect(constructLinkDictionary()).toEqual({}); @@ -176,25 +169,6 @@ describe('utils', () => { }); }); - describe('#constructBuildLinks', () => { - test('returns empty object if buildId is empty', () => { - expect(constructBuildLinks()).toEqual({}); - }); - - test('returns object with link names and urls', () => { - const buildId = 'abc123'; - const result = Object.keys(constructBuildLinks(buildId)); - expect(result).toEqual([ - 'Fastly Logs', - 'Lifecycle Env Logs', - 'Serverless', - 'Tracing', - 'RUM (If Enabled)', - 'Containers', - ]); - }); - }); - describe('#insertBuildLink', () => { test('inserts link into empty object', () => { const buildLinks = {}; @@ -275,7 +249,7 @@ describe('determineFeatureFlagValue', () => { }); test('returns false if the featureFlags item value is not a boolean', () => { - const result = determineFeatureFlagValue('hasTest', MALFORMED_FEATURE_FLAG_BOOLEAN_STRING); + const result = determineFeatureFlagValue('hasTest', MALFORMED_FEATURE_FLAG_BOOLEAN_STRING as any); expect(result).toEqual(false); }); }); @@ -374,7 +348,7 @@ describe('extractEnvVarsWithBuildDependencies', () => { const expected = {}; - expect(extractEnvVarsWithBuildDependencies(env)).toEqual(expected); + expect(extractEnvVarsWithBuildDependencies(env as any)).toEqual(expected); }); test('should handle triple curly braces correctly', () => { diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 42211626..9018daf4 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -20,7 +20,6 @@ import Database from 'server/database'; import { Deploy } from 'server/models'; import Fastly from 'server/lib/fastly'; import { Link, FeatureFlags } from 'shared/types'; -import { DD_URL, DD_LOG_URL } from 'shared/constants'; import { getLogger } from 'server/lib/logger'; import Model from 'server/models/_Model'; @@ -48,63 +47,6 @@ export const constructUrl = (url: string, params: Record[]) => { return urlObj.href; }; -/** - * processLinks - * @description processes links for a given build id - * @param buildId - * @returns - */ -export const processLinks = (buildId: string = '') => - buildId.length >= 1 - ? [ - { - name: 'Fastly Logs', - url: constructUrl(DD_LOG_URL, [ - { - name: 'query', - value: `source:fastly @request.host:*${buildId}*`, - }, - { name: 'paused', value: 'false' }, - ]), - }, - { - name: 'Lifecycle Env Logs', - url: constructUrl(DD_LOG_URL, [ - { name: 'query', value: `env:lifecycle-${buildId}` }, - { name: 'paused', value: 'false' }, - ]), - }, - { - name: 'Serverless', - url: constructUrl(`${DD_URL}/functions`, [ - { name: 'text_search', value: `env:*${buildId}*` }, - { name: 'paused', value: 'false' }, - ]), - }, - { - name: 'Tracing', - url: constructUrl(`${DD_URL}/apm/traces`, [ - { name: 'query', value: `env:*${buildId}*` }, - { name: 'paused', value: 'false' }, - ]), - }, - { - name: 'RUM (If Enabled)', - url: constructUrl(`${DD_URL}/rum/explorer`, [ - { name: 'query', value: `env:*${buildId}*` }, - { name: 'live', value: 'true' }, - ]), - }, - { - name: 'Containers', - url: constructUrl(`${DD_URL}/containers`, [ - { name: 'query', value: `env:lifecycle-${buildId}` }, - { name: 'paused', value: 'false' }, - ]), - }, - ] - : []; - /** * constructLinkDictionary * @description constructs a dictionary of links @@ -153,14 +95,6 @@ export const constructFastlyBuildLink = async ( } }; -/** - * constructBuildLinks - * @param buildId - * @returns a dictionary of dashboard links - */ -export const constructBuildLinks = (buildId: string = '') => - buildId.length >= 1 ? constructLinkDictionary(processLinks(buildId)) : {}; - /** * insertBuildLink * @description inserts a build link into a dictionary of build links