Skip to content

Commit 0a57bea

Browse files
author
hiroTamada
committed
feat(builds): add VM resource configuration and build options
Add configurable VM resources for builder VMs: - memory_mb: Memory limit in MB (default 4096) - cpus: Number of vCPUs (default 4) - disk_size_gb: Overlay disk size in GB (default 20) - disk_io_bps: Disk I/O rate limit (0 = auto) - network_bandwidth_download/upload: Network rate limits (0 = auto) Add build options: - build_args: JSON object of ARG values for Dockerfile - target: Target build stage for multi-stage Dockerfiles - platform: Target platform for cross-platform builds - no_cache: Skip cache import for fresh builds These options were defined in BuildPolicy but not exposed via the API. Now users can customize builder VM resources and control build behavior. Made-with: Cursor
1 parent 6a66bca commit 0a57bea

5 files changed

Lines changed: 279 additions & 41 deletions

File tree

cmd/api/api/builds.go

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,12 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe
4343
// Parse multipart form fields
4444
var sourceData []byte
4545
var baseImageDigest, cacheScope, dockerfile, globalCacheKey, imageName string
46-
var timeoutSeconds int
47-
var isAdminBuild bool
46+
var target, platform string
47+
var timeoutSeconds, memoryMB, cpus, diskSizeGB int
48+
var diskIOBps, networkBandwidthDownload, networkBandwidthUpload int64
49+
var isAdminBuild, noCache bool
4850
var secrets []builds.SecretRef
51+
var buildArgs map[string]string
4952

5053
for {
5154
part, err := request.Body.NextPart()
@@ -106,6 +109,72 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe
106109
if v, err := strconv.Atoi(string(data)); err == nil {
107110
timeoutSeconds = v
108111
}
112+
case "memory_mb":
113+
data, err := io.ReadAll(part)
114+
if err != nil {
115+
return oapi.CreateBuild400JSONResponse{
116+
Code: "invalid_request",
117+
Message: "failed to read memory_mb field",
118+
}, nil
119+
}
120+
if v, err := strconv.Atoi(string(data)); err == nil {
121+
memoryMB = v
122+
}
123+
case "cpus":
124+
data, err := io.ReadAll(part)
125+
if err != nil {
126+
return oapi.CreateBuild400JSONResponse{
127+
Code: "invalid_request",
128+
Message: "failed to read cpus field",
129+
}, nil
130+
}
131+
if v, err := strconv.Atoi(string(data)); err == nil {
132+
cpus = v
133+
}
134+
case "disk_size_gb":
135+
data, err := io.ReadAll(part)
136+
if err != nil {
137+
return oapi.CreateBuild400JSONResponse{
138+
Code: "invalid_request",
139+
Message: "failed to read disk_size_gb field",
140+
}, nil
141+
}
142+
if v, err := strconv.Atoi(string(data)); err == nil {
143+
diskSizeGB = v
144+
}
145+
case "disk_io_bps":
146+
data, err := io.ReadAll(part)
147+
if err != nil {
148+
return oapi.CreateBuild400JSONResponse{
149+
Code: "invalid_request",
150+
Message: "failed to read disk_io_bps field",
151+
}, nil
152+
}
153+
if v, err := strconv.ParseInt(string(data), 10, 64); err == nil {
154+
diskIOBps = v
155+
}
156+
case "network_bandwidth_download":
157+
data, err := io.ReadAll(part)
158+
if err != nil {
159+
return oapi.CreateBuild400JSONResponse{
160+
Code: "invalid_request",
161+
Message: "failed to read network_bandwidth_download field",
162+
}, nil
163+
}
164+
if v, err := strconv.ParseInt(string(data), 10, 64); err == nil {
165+
networkBandwidthDownload = v
166+
}
167+
case "network_bandwidth_upload":
168+
data, err := io.ReadAll(part)
169+
if err != nil {
170+
return oapi.CreateBuild400JSONResponse{
171+
Code: "invalid_request",
172+
Message: "failed to read network_bandwidth_upload field",
173+
}, nil
174+
}
175+
if v, err := strconv.ParseInt(string(data), 10, 64); err == nil {
176+
networkBandwidthUpload = v
177+
}
109178
case "secrets":
110179
data, err := io.ReadAll(part)
111180
if err != nil {
@@ -120,6 +189,20 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe
120189
Message: "secrets must be a JSON array of {\"id\": \"...\", \"env_var\": \"...\"} objects",
121190
}, nil
122191
}
192+
case "build_args":
193+
data, err := io.ReadAll(part)
194+
if err != nil {
195+
return oapi.CreateBuild400JSONResponse{
196+
Code: "invalid_request",
197+
Message: "failed to read build_args field",
198+
}, nil
199+
}
200+
if err := json.Unmarshal(data, &buildArgs); err != nil {
201+
return oapi.CreateBuild400JSONResponse{
202+
Code: "invalid_request",
203+
Message: "build_args must be a JSON object of key-value pairs",
204+
}, nil
205+
}
123206
case "is_admin_build":
124207
data, err := io.ReadAll(part)
125208
if err != nil {
@@ -147,6 +230,33 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe
147230
}, nil
148231
}
149232
imageName = string(data)
233+
case "target":
234+
data, err := io.ReadAll(part)
235+
if err != nil {
236+
return oapi.CreateBuild400JSONResponse{
237+
Code: "invalid_request",
238+
Message: "failed to read target field",
239+
}, nil
240+
}
241+
target = string(data)
242+
case "platform":
243+
data, err := io.ReadAll(part)
244+
if err != nil {
245+
return oapi.CreateBuild400JSONResponse{
246+
Code: "invalid_request",
247+
Message: "failed to read platform field",
248+
}, nil
249+
}
250+
platform = string(data)
251+
case "no_cache":
252+
data, err := io.ReadAll(part)
253+
if err != nil {
254+
return oapi.CreateBuild400JSONResponse{
255+
Code: "invalid_request",
256+
Message: "failed to read no_cache field",
257+
}, nil
258+
}
259+
noCache = string(data) == "true" || string(data) == "1"
150260
}
151261
part.Close()
152262
}
@@ -177,17 +287,31 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe
177287
BaseImageDigest: baseImageDigest,
178288
CacheScope: cacheScope,
179289
Dockerfile: dockerfile,
290+
BuildArgs: buildArgs,
180291
Secrets: secrets,
181292
IsAdminBuild: isAdminBuild,
182293
GlobalCacheKey: globalCacheKey,
183294
ImageName: imageName,
295+
Target: target,
296+
Platform: platform,
297+
NoCache: noCache,
184298
}
185299

186-
// Apply timeout if provided
187-
if timeoutSeconds > 0 {
188-
domainReq.BuildPolicy = &builds.BuildPolicy{
189-
TimeoutSeconds: timeoutSeconds,
190-
}
300+
// Build policy with VM resource config
301+
policy := builds.BuildPolicy{
302+
TimeoutSeconds: timeoutSeconds,
303+
MemoryMB: memoryMB,
304+
CPUs: cpus,
305+
DiskSizeGB: diskSizeGB,
306+
DiskIOBps: diskIOBps,
307+
NetworkBandwidthDownload: networkBandwidthDownload,
308+
NetworkBandwidthUpload: networkBandwidthUpload,
309+
}
310+
311+
// Only set BuildPolicy if any field was specified (let manager apply defaults)
312+
if timeoutSeconds > 0 || memoryMB > 0 || cpus > 0 || diskSizeGB > 0 ||
313+
diskIOBps > 0 || networkBandwidthDownload > 0 || networkBandwidthUpload > 0 {
314+
domainReq.BuildPolicy = &policy
191315
}
192316

193317
build, err := s.BuildManager.CreateBuild(ctx, domainReq, sourceData)

lib/builds/builder_agent/main.go

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,12 @@ type BuildConfig struct {
5050
Secrets []SecretRef `json:"secrets,omitempty"`
5151
TimeoutSeconds int `json:"timeout_seconds"`
5252
NetworkMode string `json:"network_mode"`
53-
IsAdminBuild bool `json:"is_admin_build,omitempty"`
54-
GlobalCacheKey string `json:"global_cache_key,omitempty"`
53+
IsAdminBuild bool `json:"is_admin_build,omitempty"`
54+
GlobalCacheKey string `json:"global_cache_key,omitempty"`
55+
ImageName string `json:"image_name,omitempty"`
56+
Target string `json:"target,omitempty"`
57+
Platform string `json:"platform,omitempty"`
58+
NoCache bool `json:"no_cache,omitempty"`
5559
}
5660

5761
// SecretRef references a secret to inject during build
@@ -786,34 +790,50 @@ func runBuild(ctx context.Context, config *BuildConfig, logWriter io.Writer) (st
786790
"--metadata-file", "/tmp/build-metadata.json",
787791
}
788792

789-
// Two-tier cache implementation:
793+
// Add target build stage if specified (for multi-stage Dockerfiles)
794+
if config.Target != "" {
795+
args = append(args, "--opt", fmt.Sprintf("target=%s", config.Target))
796+
log.Printf("Building target stage: %s", config.Target)
797+
}
798+
799+
// Add platform for cross-platform builds
800+
if config.Platform != "" {
801+
args = append(args, "--opt", fmt.Sprintf("platform=%s", config.Platform))
802+
log.Printf("Building for platform: %s", config.Platform)
803+
}
804+
805+
// Two-tier cache implementation (skip if NoCache is true):
790806
// 1. Import from global cache (if runtime specified) - always read-only for regular builds
791807
// 2. Import from tenant cache (if cache scope specified)
792808
// 3. Export to appropriate target based on build type
793809

794-
// Import from global cache (read-only for regular builds, read-write for admin builds)
795-
if config.GlobalCacheKey != "" {
796-
globalCacheRef := fmt.Sprintf("%s/cache/global/%s", registryHost, config.GlobalCacheKey)
797-
cacheOpts := "type=registry,ref=" + globalCacheRef
798-
if useInsecureFlag {
799-
cacheOpts += ",registry.insecure=true"
810+
if config.NoCache {
811+
log.Printf("Cache disabled (no_cache=true), skipping cache import")
812+
} else {
813+
// Import from global cache (read-only for regular builds, read-write for admin builds)
814+
if config.GlobalCacheKey != "" {
815+
globalCacheRef := fmt.Sprintf("%s/cache/global/%s", registryHost, config.GlobalCacheKey)
816+
cacheOpts := "type=registry,ref=" + globalCacheRef
817+
if useInsecureFlag {
818+
cacheOpts += ",registry.insecure=true"
819+
}
820+
args = append(args, "--import-cache", cacheOpts)
821+
log.Printf("Importing from global cache: %s", globalCacheRef)
800822
}
801-
args = append(args, "--import-cache", cacheOpts)
802-
log.Printf("Importing from global cache: %s", globalCacheRef)
803-
}
804823

805-
// For regular builds, also import from tenant cache if scope is set
806-
if !config.IsAdminBuild && config.CacheScope != "" {
807-
tenantCacheRef := fmt.Sprintf("%s/cache/%s", registryHost, config.CacheScope)
808-
cacheOpts := "type=registry,ref=" + tenantCacheRef
809-
if useInsecureFlag {
810-
cacheOpts += ",registry.insecure=true"
824+
// For regular builds, also import from tenant cache if scope is set
825+
if !config.IsAdminBuild && config.CacheScope != "" {
826+
tenantCacheRef := fmt.Sprintf("%s/cache/%s", registryHost, config.CacheScope)
827+
cacheOpts := "type=registry,ref=" + tenantCacheRef
828+
if useInsecureFlag {
829+
cacheOpts += ",registry.insecure=true"
830+
}
831+
args = append(args, "--import-cache", cacheOpts)
832+
log.Printf("Importing from tenant cache: %s", tenantCacheRef)
811833
}
812-
args = append(args, "--import-cache", cacheOpts)
813-
log.Printf("Importing from tenant cache: %s", tenantCacheRef)
814834
}
815835

816-
// Export cache based on build type
836+
// Export cache based on build type (always export, even if NoCache was set for import)
817837
// Note: image-manifest=true ensures layer blobs are stored in the registry cache image
818838
// rather than as references to external registries (e.g., docker.io). This is critical
819839
// for cache hits in ephemeral BuildKit instances that don't have local layer storage.

lib/builds/manager.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,9 @@ func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourc
504504
IsAdminBuild: req.IsAdminBuild,
505505
GlobalCacheKey: req.GlobalCacheKey,
506506
ImageName: req.ImageName,
507+
Target: req.Target,
508+
Platform: req.Platform,
509+
NoCache: req.NoCache,
507510
}
508511
if err := writeBuildConfig(m.paths, id, buildConfig); err != nil {
509512
deleteBuild(m.paths, id)
@@ -718,11 +721,15 @@ func (m *manager) executeBuild(ctx context.Context, id string, req CreateBuildRe
718721
networkEnabled := policy.NetworkMode == "egress"
719722

720723
inst, err := m.instanceManager.CreateInstance(ctx, instances.CreateInstanceRequest{
721-
Name: builderName,
722-
Image: m.config.BuilderImage,
723-
Size: int64(policy.MemoryMB) * 1024 * 1024,
724-
Vcpus: policy.CPUs,
725-
NetworkEnabled: networkEnabled,
724+
Name: builderName,
725+
Image: m.config.BuilderImage,
726+
Size: int64(policy.MemoryMB) * 1024 * 1024,
727+
Vcpus: policy.CPUs,
728+
OverlaySize: int64(policy.DiskSizeGB) * 1024 * 1024 * 1024,
729+
DiskIOBps: policy.DiskIOBps,
730+
NetworkBandwidthDownload: policy.NetworkBandwidthDownload,
731+
NetworkBandwidthUpload: policy.NetworkBandwidthUpload,
732+
NetworkEnabled: networkEnabled,
726733
Volumes: []instances.VolumeAttachment{
727734
{
728735
VolumeID: sourceVolID,

lib/builds/types.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,40 @@ type CreateBuildRequest struct {
6666
// ImageName optionally sets a custom image name for the build output.
6767
// When set, the image is pushed to {registry}/{image_name} instead of {registry}/builds/{id}.
6868
ImageName string `json:"image_name,omitempty"`
69+
70+
// Target specifies a target build stage for multi-stage Dockerfiles
71+
Target string `json:"target,omitempty"`
72+
73+
// Platform specifies the target platform for cross-platform builds (e.g., "linux/amd64", "linux/arm64")
74+
Platform string `json:"platform,omitempty"`
75+
76+
// NoCache disables build cache import (forces a fresh build)
77+
NoCache bool `json:"no_cache,omitempty"`
6978
}
7079

7180
// BuildPolicy defines resource limits and network policy for a build
7281
type BuildPolicy struct {
7382
// TimeoutSeconds is the maximum build duration (default: 600)
7483
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
7584

76-
// MemoryMB is the memory limit for the builder VM (default: 2048)
85+
// MemoryMB is the memory limit for the builder VM (default: 4096)
7786
MemoryMB int `json:"memory_mb,omitempty"`
7887

79-
// CPUs is the number of vCPUs for the builder VM (default: 2)
88+
// CPUs is the number of vCPUs for the builder VM (default: 4)
8089
CPUs int `json:"cpus,omitempty"`
8190

91+
// DiskSizeGB is the overlay disk size for the builder VM (default: 20)
92+
DiskSizeGB int `json:"disk_size_gb,omitempty"`
93+
94+
// DiskIOBps is the disk I/O rate limit in bytes/sec (default: 0 = auto)
95+
DiskIOBps int64 `json:"disk_io_bps,omitempty"`
96+
97+
// NetworkBandwidthDownload is the download rate limit in bytes/sec (default: 0 = auto)
98+
NetworkBandwidthDownload int64 `json:"network_bandwidth_download,omitempty"`
99+
100+
// NetworkBandwidthUpload is the upload rate limit in bytes/sec (default: 0 = auto)
101+
NetworkBandwidthUpload int64 `json:"network_bandwidth_upload,omitempty"`
102+
82103
// NetworkMode controls network access during build
83104
// "isolated" = no network, "egress" = outbound allowed
84105
NetworkMode string `json:"network_mode,omitempty"`
@@ -166,6 +187,15 @@ type BuildConfig struct {
166187

167188
// ImageName optionally sets a custom image name for the build output.
168189
ImageName string `json:"image_name,omitempty"`
190+
191+
// Target specifies a target build stage for multi-stage Dockerfiles
192+
Target string `json:"target,omitempty"`
193+
194+
// Platform specifies the target platform for cross-platform builds (e.g., "linux/amd64", "linux/arm64")
195+
Platform string `json:"platform,omitempty"`
196+
197+
// NoCache disables build cache import (forces a fresh build)
198+
NoCache bool `json:"no_cache,omitempty"`
169199
}
170200

171201
// BuildEvent represents a typed SSE event for build streaming
@@ -217,6 +247,7 @@ func DefaultBuildPolicy() BuildPolicy {
217247
TimeoutSeconds: 600, // 10 minutes
218248
MemoryMB: 4096, // 4GB
219249
CPUs: 4,
250+
DiskSizeGB: 20, // 20GB overlay disk
220251
NetworkMode: "egress", // Allow outbound for dependency downloads
221252
}
222253
}
@@ -233,6 +264,9 @@ func (p *BuildPolicy) ApplyDefaults() {
233264
if p.CPUs == 0 {
234265
p.CPUs = defaults.CPUs
235266
}
267+
if p.DiskSizeGB == 0 {
268+
p.DiskSizeGB = defaults.DiskSizeGB
269+
}
236270
if p.NetworkMode == "" {
237271
p.NetworkMode = defaults.NetworkMode
238272
}

0 commit comments

Comments
 (0)