diff --git a/docs/schema/yaml/1.0.0.yaml b/docs/schema/yaml/1.0.0.yaml index def8232..13e04b5 100644 --- a/docs/schema/yaml/1.0.0.yaml +++ b/docs/schema/yaml/1.0.0.yaml @@ -177,6 +177,14 @@ services: builder: # @param services.helm.docker.builder.engine engine: '' + # @param services.helm.docker.builder.resources + resources: + # @param services.helm.docker.builder.resources.requests + requests: + + # @param services.helm.docker.builder.resources.limits + limits: + # @param services.helm.docker.app (required) app: # @param services.helm.docker.app.afterBuildPipelineConfig @@ -372,6 +380,14 @@ services: builder: # @param services.github.docker.builder.engine engine: '' + # @param services.github.docker.builder.resources + resources: + # @param services.github.docker.builder.resources.requests + requests: + + # @param services.github.docker.builder.resources.limits + limits: + # @param services.github.docker.app (required) app: # @param services.github.docker.app.afterBuildPipelineConfig diff --git a/src/server/lib/jsonschema/schemas/1.0.0.json b/src/server/lib/jsonschema/schemas/1.0.0.json index da487a4..4fb544c 100644 --- a/src/server/lib/jsonschema/schemas/1.0.0.json +++ b/src/server/lib/jsonschema/schemas/1.0.0.json @@ -342,6 +342,24 @@ "properties": { "engine": { "type": "string" + }, + "resources": { + "type": "object", + "additionalProperties": false, + "properties": { + "requests": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "limits": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } } } }, @@ -708,6 +726,24 @@ "properties": { "engine": { "type": "string" + }, + "resources": { + "type": "object", + "additionalProperties": false, + "properties": { + "requests": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "limits": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } } } }, diff --git a/src/server/lib/nativeBuild/__tests__/buildkit.test.ts b/src/server/lib/nativeBuild/__tests__/buildkit.test.ts index 7ae40ed..1ce03c6 100644 --- a/src/server/lib/nativeBuild/__tests__/buildkit.test.ts +++ b/src/server/lib/nativeBuild/__tests__/buildkit.test.ts @@ -251,6 +251,181 @@ describe('buildkitBuild', () => { }); }); +describe('build resource precedence', () => { + 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('uses yaml resources (options.resources) over global config', async () => { + const globalConfig = { + buildDefaults: { + resources: { + buildkit: { + requests: { cpu: '1', memory: '2Gi' }, + limits: { cpu: '2', memory: '4Gi' }, + }, + }, + }, + }; + (GlobalConfigService.getInstance as jest.Mock).mockReturnValue({ + getAllConfigs: jest.fn().mockResolvedValue(globalConfig), + }); + + const yamlResources = { + requests: { cpu: '4', memory: '8Gi' }, + limits: { cpu: '8', memory: '16Gi' }, + }; + + await buildkitBuild(mockDeploy, { ...baseOptions, resources: yamlResources }); + + 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('cpu: "4"'); + expect(fullCommand).toContain('memory: "8Gi"'); + expect(fullCommand).toContain('cpu: "8"'); + expect(fullCommand).toContain('memory: "16Gi"'); + expect(fullCommand).not.toContain('cpu: "1"'); + expect(fullCommand).not.toContain('cpu: "2"'); + }); + + it('falls back to global config resources when yaml resources not set', async () => { + const globalConfig = { + buildDefaults: { + resources: { + buildkit: { + requests: { cpu: '1', memory: '2Gi' }, + limits: { cpu: '3', memory: '6Gi' }, + }, + }, + }, + }; + (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('cpu: "1"'); + expect(fullCommand).toContain('memory: "2Gi"'); + expect(fullCommand).toContain('cpu: "3"'); + expect(fullCommand).toContain('memory: "6Gi"'); + }); + + it('falls back to default resources when neither yaml nor global config set', 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('cpu: "500m"'); + expect(fullCommand).toContain('memory: "1Gi"'); + expect(fullCommand).toContain('cpu: "2"'); + expect(fullCommand).toContain('memory: "4Gi"'); + }); + + it('uses yaml resources for kaniko over global config', async () => { + const { kanikoBuild } = require('../engines'); + const globalConfig = { + buildDefaults: { + resources: { + kaniko: { + requests: { cpu: '300m', memory: '750Mi' }, + limits: { cpu: '1', memory: '2Gi' }, + }, + }, + }, + }; + (GlobalConfigService.getInstance as jest.Mock).mockReturnValue({ + getAllConfigs: jest.fn().mockResolvedValue(globalConfig), + }); + + const yamlResources = { + requests: { cpu: '2', memory: '4Gi' }, + limits: { cpu: '4', memory: '8Gi' }, + }; + + await kanikoBuild(mockDeploy, { ...baseOptions, resources: yamlResources }); + + 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('cpu: "2"'); + expect(fullCommand).toContain('memory: "4Gi"'); + expect(fullCommand).toContain('cpu: "4"'); + expect(fullCommand).toContain('memory: "8Gi"'); + expect(fullCommand).not.toContain('cpu: "300m"'); + expect(fullCommand).not.toContain('memory: "750Mi"'); + }); + + it('uses partial yaml resources without merging with global config', async () => { + const globalConfig = { + buildDefaults: { + resources: { + buildkit: { + requests: { cpu: '1', memory: '2Gi' }, + limits: { cpu: '2', memory: '4Gi' }, + }, + }, + }, + }; + (GlobalConfigService.getInstance as jest.Mock).mockReturnValue({ + getAllConfigs: jest.fn().mockResolvedValue(globalConfig), + }); + + const yamlResources = { + requests: { cpu: '4', memory: '8Gi' }, + }; + + await buildkitBuild(mockDeploy, { ...baseOptions, resources: yamlResources }); + + 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('cpu: "4"'); + expect(fullCommand).toContain('memory: "8Gi"'); + }); +}); + describe('generateSecretArgsScript', () => { it('returns comment when no secret keys provided', () => { expect(generateSecretArgsScript(undefined)).toBe('# No secret env keys'); diff --git a/src/server/lib/yamlSchemas/schema_1_0_0/docker.ts b/src/server/lib/yamlSchemas/schema_1_0_0/docker.ts index c91fb45..4b1ecfb 100644 --- a/src/server/lib/yamlSchemas/schema_1_0_0/docker.ts +++ b/src/server/lib/yamlSchemas/schema_1_0_0/docker.ts @@ -26,6 +26,14 @@ export const docker = { additionalProperties: true, properties: { engine: { type: 'string' }, + resources: { + type: 'object', + additionalProperties: false, + properties: { + requests: { type: 'object', additionalProperties: { type: 'string' } }, + limits: { type: 'object', additionalProperties: { type: 'string' } }, + }, + }, }, }, app: { diff --git a/src/server/models/yaml/YamlService.ts b/src/server/models/yaml/YamlService.ts index 502f1ee..18f4ed3 100644 --- a/src/server/models/yaml/YamlService.ts +++ b/src/server/models/yaml/YamlService.ts @@ -285,6 +285,10 @@ export interface ServiceDiskConfig { export interface Builder { readonly engine?: string; + readonly resources?: { + readonly requests?: Record; + readonly limits?: Record; + }; } /** diff --git a/src/server/services/deploy.ts b/src/server/services/deploy.ts index 7f5e3f1..abcee2d 100644 --- a/src/server/services/deploy.ts +++ b/src/server/services/deploy.ts @@ -1107,6 +1107,7 @@ export default class DeployService extends BaseService { buildId: String(deploy.build.id), deployUuid: deploy.uuid, cacheRegistry: buildDefaults?.cacheRegistry, + resources: deployable.builder?.resources, secretRefs: buildSecretNames, secretEnvKeys: Array.from(secretEnvKeys), };