diff --git a/src/server/lib/kubernetes/jobFactory.ts b/src/server/lib/kubernetes/jobFactory.ts index 37ea3f1..79f9c30 100644 --- a/src/server/lib/kubernetes/jobFactory.ts +++ b/src/server/lib/kubernetes/jobFactory.ts @@ -27,6 +27,7 @@ export interface JobConfig { ttl?: number; labels: Record; annotations?: Record; + podAnnotations?: Record; initContainers?: any[]; containers: any[]; volumes?: any[]; @@ -46,6 +47,7 @@ export function createKubernetesJob(config: JobConfig): V1Job { ttl, labels, annotations = {}, + podAnnotations = {}, initContainers = [], containers, volumes = [], @@ -82,6 +84,7 @@ export function createKubernetesJob(config: JobConfig): V1Job { 'app.kubernetes.io/component': component, ...(labels['lc-service'] && { 'lc-service': labels['lc-service'] }), }, + ...(Object.keys(podAnnotations).length > 0 && { annotations: podAnnotations }), }, spec: { serviceAccountName: serviceAccount, @@ -117,6 +120,7 @@ export interface BuildJobConfig { initContainers: any[]; containers: any[]; volumes?: any[]; + podAnnotations?: Record; } export function createBuildJob(config: BuildJobConfig): V1Job { @@ -143,6 +147,7 @@ export function createBuildJob(config: BuildJobConfig): V1Job { 'lifecycle.io/dockerfile': config.dockerfilePath, 'lifecycle.io/ecr-repo': config.ecrRepo, }, + podAnnotations: config.podAnnotations, initContainers: config.initContainers, containers: config.containers, volumes: config.volumes || [{ name: 'workspace', emptyDir: {} }], diff --git a/src/server/lib/nativeBuild/__tests__/buildkit.test.ts b/src/server/lib/nativeBuild/__tests__/buildkit.test.ts index 1ce03c6..116c07f 100644 --- a/src/server/lib/nativeBuild/__tests__/buildkit.test.ts +++ b/src/server/lib/nativeBuild/__tests__/buildkit.test.ts @@ -426,6 +426,145 @@ describe('build resource precedence', () => { }); }); +describe('build pod annotations', () => { + const mockDeploy = { + deployable: { name: 'test-service' }, + $fetchGraph: jest.fn(), + build: { isStatic: false }, + } as any; + + const baseOptions: BuildkitBuildOptions = { + ecrRepo: 'test-repo', + ecrDomain: '123456789.dkr.ecr.us-east-1.amazonaws.com', + envVars: { NODE_ENV: 'production' }, + dockerfilePath: 'Dockerfile', + tag: 'v1.0.0', + revision: 'abc123def456789', + repo: 'owner/repo', + branch: 'main', + namespace: 'env-test-123', + buildId: '456', + deployUuid: 'test-service-abc123', + jobTimeout: 1800, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (getGitHubToken as jest.Mock).mockResolvedValue('github-token-123'); + (shellPromise as jest.Mock).mockResolvedValue(''); + (waitForJobAndGetLogs as jest.Mock).mockResolvedValue({ + logs: 'Build completed successfully', + success: true, + }); + }); + + it('includes safe-to-evict annotation on pod template by default', async () => { + (GlobalConfigService.getInstance as jest.Mock).mockReturnValue({ + getAllConfigs: jest.fn().mockResolvedValue({}), + }); + + await buildkitBuild(mockDeploy, baseOptions); + + const kubectlCalls = (shellPromise as jest.Mock).mock.calls; + const applyCall = kubectlCalls.find((call) => call[0].includes('kubectl apply')); + const fullCommand = applyCall[0]; + + expect(fullCommand).toContain('cluster-autoscaler.kubernetes.io/safe-to-evict: "false"'); + }); + + it('applies custom podAnnotations from global config to pod template', async () => { + const globalConfig = { + buildDefaults: { + podAnnotations: { + 'custom-annotation/team': 'platform', + 'custom-annotation/cost-center': 'engineering', + }, + }, + }; + (GlobalConfigService.getInstance as jest.Mock).mockReturnValue({ + getAllConfigs: jest.fn().mockResolvedValue(globalConfig), + }); + + await buildkitBuild(mockDeploy, baseOptions); + + const kubectlCalls = (shellPromise as jest.Mock).mock.calls; + const applyCall = kubectlCalls.find((call) => call[0].includes('kubectl apply')); + const fullCommand = applyCall[0]; + + expect(fullCommand).toContain('custom-annotation/team: "platform"'); + expect(fullCommand).toContain('custom-annotation/cost-center: "engineering"'); + expect(fullCommand).toContain('cluster-autoscaler.kubernetes.io/safe-to-evict: "false"'); + }); + + it('applies per-service podAnnotations from options', async () => { + (GlobalConfigService.getInstance as jest.Mock).mockReturnValue({ + getAllConfigs: jest.fn().mockResolvedValue({}), + }); + + await buildkitBuild(mockDeploy, { + ...baseOptions, + podAnnotations: { 'my-org/service-tier': 'critical' }, + }); + + const kubectlCalls = (shellPromise as jest.Mock).mock.calls; + const applyCall = kubectlCalls.find((call) => call[0].includes('kubectl apply')); + const fullCommand = applyCall[0]; + + expect(fullCommand).toContain('my-org/service-tier: "critical"'); + expect(fullCommand).toContain('cluster-autoscaler.kubernetes.io/safe-to-evict: "false"'); + }); + + it('per-service podAnnotations override global config podAnnotations', async () => { + const globalConfig = { + buildDefaults: { + podAnnotations: { + 'my-org/team': 'platform', + }, + }, + }; + (GlobalConfigService.getInstance as jest.Mock).mockReturnValue({ + getAllConfigs: jest.fn().mockResolvedValue(globalConfig), + }); + + await buildkitBuild(mockDeploy, { + ...baseOptions, + podAnnotations: { 'my-org/team': 'frontend' }, + }); + + const kubectlCalls = (shellPromise as jest.Mock).mock.calls; + const applyCall = kubectlCalls.find((call) => call[0].includes('kubectl apply')); + const fullCommand = applyCall[0]; + + expect(fullCommand).toContain('my-org/team: "frontend"'); + expect(fullCommand).not.toContain('my-org/team: "platform"'); + }); + + it('hardcoded safe-to-evict cannot be overridden by global config or options', async () => { + const globalConfig = { + buildDefaults: { + podAnnotations: { + 'cluster-autoscaler.kubernetes.io/safe-to-evict': 'true', + }, + }, + }; + (GlobalConfigService.getInstance as jest.Mock).mockReturnValue({ + getAllConfigs: jest.fn().mockResolvedValue(globalConfig), + }); + + await buildkitBuild(mockDeploy, { + ...baseOptions, + podAnnotations: { 'cluster-autoscaler.kubernetes.io/safe-to-evict': 'true' }, + }); + + const kubectlCalls = (shellPromise as jest.Mock).mock.calls; + const applyCall = kubectlCalls.find((call) => call[0].includes('kubectl apply')); + const fullCommand = applyCall[0]; + + expect(fullCommand).toContain('cluster-autoscaler.kubernetes.io/safe-to-evict: "false"'); + expect(fullCommand).not.toContain('cluster-autoscaler.kubernetes.io/safe-to-evict: "true"'); + }); +}); + describe('generateSecretArgsScript', () => { it('returns comment when no secret keys provided', () => { expect(generateSecretArgsScript(undefined)).toBe('# No secret env keys'); diff --git a/src/server/lib/nativeBuild/engines.ts b/src/server/lib/nativeBuild/engines.ts index 9ddfc55..b9ce1f7 100644 --- a/src/server/lib/nativeBuild/engines.ts +++ b/src/server/lib/nativeBuild/engines.ts @@ -50,6 +50,7 @@ export interface NativeBuildOptions { }; secretRefs?: string[]; secretEnvKeys?: string[]; + podAnnotations?: Record; } interface BuildEngine { @@ -279,6 +280,11 @@ export async function buildWithEngine( const serviceAccount = options.serviceAccount || buildDefaults.serviceAccount || 'native-build-sa'; const jobTimeout = options.jobTimeout || buildDefaults.jobTimeout || 2100; const resources = options.resources || buildDefaults.resources?.[engineName] || DEFAULT_BUILD_RESOURCES[engineName]; + const podAnnotations = { + ...buildDefaults.podAnnotations, + ...options.podAnnotations, + 'cluster-autoscaler.kubernetes.io/safe-to-evict': 'false', + }; const cacheRegistry = options.cacheRegistry || buildDefaults.cacheRegistry; @@ -424,6 +430,7 @@ export async function buildWithEngine( emptyDir: {}, }, ], + podAnnotations, }); const jobYaml = yaml.dump(job, { quotingType: '"', forceQuotes: true }); diff --git a/src/server/models/yaml/YamlService.ts b/src/server/models/yaml/YamlService.ts index 18f4ed3..1616f9e 100644 --- a/src/server/models/yaml/YamlService.ts +++ b/src/server/models/yaml/YamlService.ts @@ -289,6 +289,7 @@ export interface Builder { readonly requests?: Record; readonly limits?: Record; }; + readonly podAnnotations?: Record; } /** diff --git a/src/server/services/deploy.ts b/src/server/services/deploy.ts index abcee2d..aafc691 100644 --- a/src/server/services/deploy.ts +++ b/src/server/services/deploy.ts @@ -1108,6 +1108,7 @@ export default class DeployService extends BaseService { deployUuid: deploy.uuid, cacheRegistry: buildDefaults?.cacheRegistry, resources: deployable.builder?.resources, + podAnnotations: deployable.builder?.podAnnotations, secretRefs: buildSecretNames, secretEnvKeys: Array.from(secretEnvKeys), }; diff --git a/src/server/services/types/globalConfig.ts b/src/server/services/types/globalConfig.ts index 2f7d7a9..acfd2ce 100644 --- a/src/server/services/types/globalConfig.ts +++ b/src/server/services/types/globalConfig.ts @@ -127,6 +127,7 @@ export type BuildDefaults = { jobTimeout?: number; serviceAccount?: string; cacheRegistry?: string; + podAnnotations?: Record; resources?: { buildkit?: ResourceRequirements; kaniko?: ResourceRequirements;