Skip to content

Commit 8adb8ac

Browse files
pditommasoclaude
andcommitted
Add multi-platform container builds with per-architecture scanning
Refactor ContainerPlatform to support multi-arch builds natively, replacing MultiContainerPlatform with a unified model. Fan out security scans per architecture since Trivy only accepts a single --platform flag. Key changes: - Consolidate ContainerPlatform to handle both single and multi-arch - Add ScanIds helper for encoding/decoding per-platform scan IDs - Fan out scans in ContainerScanServiceImpl per architecture - Add BuildRequest.withScanId() for propagating multi-scan IDs - Update views and email templates for per-arch scan links - Poll all per-arch scans in ContainerStatusServiceImpl - Extract ScanIds.populateScanBinding() to DRY scan binding logic Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 390e1f5 commit 8adb8ac

28 files changed

+662
-174
lines changed

src/main/groovy/io/seqera/wave/controller/ContainerController.groovy

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import io.seqera.wave.service.request.ContainerRequestService
7777
import io.seqera.wave.service.request.ContainerStatusService
7878
import io.seqera.wave.service.request.TokenData
7979
import io.seqera.wave.service.scan.ContainerScanService
80+
import io.seqera.wave.service.scan.ScanIds
8081
import io.seqera.wave.service.validation.ValidationService
8182
import io.seqera.wave.service.validation.ValidationServiceImpl
8283
import io.seqera.wave.tower.PlatformId
@@ -404,6 +405,21 @@ class ContainerController {
404405
)
405406
}
406407

408+
protected String makeMultiPlatformScanId(BuildRequest build, SubmitContainerTokenRequest req) {
409+
if( !scanService || !req.multiPlatform )
410+
return build.scanId
411+
final multiPlatform = ContainerPlatform.MULTI_PLATFORM
412+
final scanMode = req.scanMode!=null ? req.scanMode : ScanMode.async
413+
final scanIdByPlatform = new LinkedHashMap<String, String>()
414+
for( String arch : multiPlatform.archs ) {
415+
final platform = "${multiPlatform.os}/${arch}" as String
416+
final id = scanService.getScanId("${build.targetImage}#${platform}", null, scanMode, req.format)
417+
if( id )
418+
scanIdByPlatform.put(id, platform)
419+
}
420+
return scanIdByPlatform ? ScanIds.encode(scanIdByPlatform) : null
421+
}
422+
407423
protected BuildTrack checkBuild(BuildRequest build, boolean dryRun) {
408424
final digest = registryProxyService.getImageDigest(build)
409425
// check for dry-run execution
@@ -424,7 +440,7 @@ class ContainerController {
424440
}
425441
}
426442

427-
protected BuildTrack checkMultiPlatformBuild(BuildRequest templateBuild, SubmitContainerTokenRequest req, PlatformId identity, String ip) {
443+
protected BuildTrack checkMultiPlatformBuild(BuildRequest templateBuild, SubmitContainerTokenRequest req, PlatformId identity, boolean dryRun) {
428444
final containerSpec = templateBuild.containerFile
429445
final condaContent = templateBuild.condaFile
430446
final buildRepository = ContainerCoordinates.parse(templateBuild.targetImage).repository
@@ -433,6 +449,14 @@ class ContainerController {
433449
final containerId = makeMultiPlatformContainerId(containerSpec, condaContent, buildRepository, req.buildContext, req.freeze ? req.containerConfig : null)
434450
final targetImage = makeTargetImage(templateBuild.format, buildRepository, containerId, condaContent, req.nameStrategy)
435451

452+
// check for dry-run execution
453+
if( dryRun ) {
454+
log.debug "== Dry-run multi-platform build request for $targetImage"
455+
final dryId = containerId + BuildRequest.SEP + '0'
456+
final digest = registryProxyService.getImageDigest(targetImage, identity)
457+
return new BuildTrack(dryId, targetImage, digest!=null, true)
458+
}
459+
436460
// check if the multi-platform image already exists
437461
final digest = registryProxyService.getImageDigest(targetImage, identity)
438462
if( digest ) {
@@ -486,8 +510,13 @@ class ContainerController {
486510
Boolean succeeded
487511
if( req.containerFile && req.multiPlatform ) {
488512
if( !buildService ) throw new UnsupportedBuildServiceException()
489-
final build = makeBuildRequest(req, identity, ip)
490-
final track = checkMultiPlatformBuild(build, req, identity, ip)
513+
final build0 = makeBuildRequest(req, identity, ip)
514+
// replace the single scanId with per-platform scanIds for multi-arch builds
515+
final multiScanId = makeMultiPlatformScanId(build0, req)
516+
final build = multiScanId != build0.scanId
517+
? build0.withScanId(multiScanId)
518+
: build0
519+
final track = checkMultiPlatformBuild(build, req, identity, req.dryRun)
491520
targetImage = track.targetImage
492521
targetContent = build.containerFile
493522
condaContent = build.condaFile

src/main/groovy/io/seqera/wave/controller/ViewController.groovy

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import io.seqera.wave.service.persistence.WaveBuildRecord
5656
import io.seqera.wave.service.persistence.WaveScanRecord
5757
import io.seqera.wave.service.scan.ContainerScanService
5858
import io.seqera.wave.service.scan.ScanEntry
59+
import io.seqera.wave.service.scan.ScanIds
5960
import io.seqera.wave.service.scan.ScanType
6061
import io.seqera.wave.service.scan.ScanVulnerability
6162
import io.seqera.wave.util.JacksonHelper
@@ -137,8 +138,7 @@ class ViewController {
137138
binding.mirror_digest = result.digest ?: '-'
138139
binding.mirror_user = result.userName ?: '-'
139140
binding.put('server_url', serverUrl)
140-
binding.scan_url = result.scanId && result.succeeded() ? "$serverUrl/view/scans/${result.scanId}" : null
141-
binding.scan_id = result.scanId
141+
ScanIds.populateScanBinding(binding, result.scanId, result.succeeded(), serverUrl)
142142
return binding
143143
}
144144

@@ -254,8 +254,7 @@ class ViewController {
254254
binding.build_condafile = result.condaFile
255255
binding.build_digest = result.digest ?: '-'
256256
binding.put('server_url', serverUrl)
257-
binding.scan_url = result.scanId && result.succeeded() ? "$serverUrl/view/scans/${result.scanId}" : null
258-
binding.scan_id = result.scanId
257+
ScanIds.populateScanBinding(binding, result.scanId, result.succeeded(), serverUrl)
259258
// inspect uri
260259
binding.inspect_url = result.succeeded() ? "$serverUrl/view/inspect?image=${result.targetImage}&platform=${result.platform}" : null
261260
// configure build logs when available
@@ -314,8 +313,7 @@ class ViewController {
314313
binding.build_url = data.buildId ? "$serverUrl/view/builds/${data.buildId}" : null
315314
binding.fusion_version = data.fusionVersion ?: '-'
316315

317-
binding.scan_id = data.scanId
318-
binding.scan_url = data.scanId ? "$serverUrl/view/scans/${data.scanId}" : null
316+
ScanIds.populateScanBinding(binding, data.scanId, true, serverUrl)
319317

320318
binding.mirror_id = data.mirror ? data.buildId : null
321319
binding.mirror_url = data.mirror ? "$serverUrl/view/mirrors/${data.buildId}" : null

src/main/groovy/io/seqera/wave/core/ContainerPlatform.groovy

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,31 +18,59 @@
1818

1919
package io.seqera.wave.core
2020

21-
import groovy.transform.Canonical
2221
import groovy.transform.CompileStatic
22+
import groovy.transform.EqualsAndHashCode
2323
import io.seqera.wave.exception.BadRequestException
2424
/**
2525
* Model a container platform
2626
* @author Paolo Di Tommaso <[email protected]>
2727
*/
28-
@Canonical
28+
@EqualsAndHashCode
2929
@CompileStatic
3030
class ContainerPlatform {
3131

32-
public static final ContainerPlatform DEFAULT = new ContainerPlatform(DEFAULT_OS, DEFAULT_ARCH)
33-
3432
public static final List<String> ARM64 = ['arm64', 'aarch64']
3533
private static final List<String> V8 = ['8','v8']
3634
public static final List<String> AMD64 = ['amd64', 'x86_64', 'x86-64']
3735
public static final List<String> ALLOWED_ARCH = AMD64 + ARM64 + ['arm']
3836
public static final String DEFAULT_ARCH = 'amd64'
3937
public static final String DEFAULT_OS = 'linux'
4038

39+
public static final ContainerPlatform DEFAULT = new ContainerPlatform(DEFAULT_OS, DEFAULT_ARCH)
40+
41+
/**
42+
* A composite platform representing linux/amd64 + linux/arm64 multi-arch builds
43+
*/
44+
public static final ContainerPlatform MULTI_PLATFORM = new ContainerPlatform('linux', ['amd64', 'arm64'])
45+
4146
final String os
4247
final String arch
4348
final String variant
49+
final List<String> archs
50+
51+
ContainerPlatform(String os, String arch, String variant=null) {
52+
this.os = os
53+
this.arch = arch
54+
this.variant = variant
55+
this.archs = List.of(arch)
56+
}
57+
58+
private ContainerPlatform(String os, List<String> archs) {
59+
assert archs.size() >= 2, "Multi-arch platform requires at least 2 architectures"
60+
this.os = os
61+
this.arch = archs[0]
62+
this.variant = null
63+
this.archs = List.copyOf(archs)
64+
}
65+
66+
boolean isMultiArch() {
67+
return archs.size() > 1
68+
}
4469

4570
String toString() {
71+
if( isMultiArch() ) {
72+
return archs.collect { "${os}/${it}" }.join(',')
73+
}
4674
def result = os + "/" + arch
4775
if( variant )
4876
result += "/" + variant
@@ -88,6 +116,16 @@ class ContainerPlatform {
88116
if( !value )
89117
throw new BadRequestException("Missing container platform attribute")
90118

119+
// handle comma-separated multi-platform values e.g. "linux/amd64,linux/arm64"
120+
if( value.contains(',') ) {
121+
final parts = value.tokenize(',').collect { it.trim() }
122+
final platforms = parts.collect { of(it) }
123+
// all platforms must share the same OS
124+
final os = platforms[0].os
125+
final archs = platforms.collect { it.arch }
126+
return new ContainerPlatform(os, archs)
127+
}
128+
91129
final items= value.tokenize('/')
92130
if( items.size()==1 )
93131
items.add(0, DEFAULT_OS)

src/main/groovy/io/seqera/wave/core/MultiContainerPlatform.groovy

Lines changed: 0 additions & 45 deletions
This file was deleted.

src/main/groovy/io/seqera/wave/proxy/ProxyClient.groovy

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ class ProxyClient {
231231
copyHeaders(headers, builder)
232232
if( authorize ) {
233233
// add authorisation header
234-
final header = loginService.getAuthorization(image, registry.auth, credentials)
234+
final header = getAuthHeader()
235235
if( header )
236236
builder.setHeader("Authorization", header)
237237
}
@@ -285,10 +285,10 @@ class ProxyClient {
285285
// https://zetcode.com/java/httpclient/
286286
final builder = HttpRequest.newBuilder(uri)
287287
.method("HEAD", HttpRequest.BodyPublishers.noBody())
288-
// copy headers
288+
// copy headers
289289
copyHeaders(headers, builder)
290290
// add authorisation header
291-
final header = loginService.getAuthorization(image, registry.auth, credentials)
291+
final header = getAuthHeader()
292292
if( header )
293293
builder.setHeader("Authorization", header)
294294
// build the request
@@ -375,7 +375,7 @@ class ProxyClient {
375375
// copy headers
376376
copyHeaders(headers, request)
377377
// add authorisation header
378-
final header = loginService.getAuthorization(image, registry.auth, credentials)
378+
final header = getAuthHeader()
379379
if( header )
380380
request.header("Authorization", header)
381381

@@ -395,7 +395,7 @@ class ProxyClient {
395395
result.add("${entry.key}: $entry.value")
396396
}
397397
// add authorisation header
398-
final header = loginService.getAuthorization(image, registry.auth, credentials)
398+
final header = getAuthHeader()
399399
if( header ) {
400400
result.add('-H')
401401
result.add("Authorization: $header")

src/main/groovy/io/seqera/wave/service/builder/BuildRequest.groovy

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,6 @@ class BuildRequest {
154154
*/
155155
final boolean noEmail
156156

157-
/**
158-
* When {@code true}, this is a multi-platform composite build (linux/amd64 + linux/arm64)
159-
*/
160-
final boolean multiPlatform
161-
162157
BuildRequest(
163158
String containerId,
164159
String containerFile,
@@ -201,7 +196,6 @@ class BuildRequest {
201196
this.compression = compression
202197
this.buildTemplate = buildTemplate
203198
this.noEmail = noEmail
204-
this.multiPlatform = false
205199
// NOTE: this is meant to be updated - automatically - when the request is submitted
206200
this.buildId = computeBuildId(containerId)
207201
}
@@ -231,13 +225,38 @@ class BuildRequest {
231225
this.buildId = opts.buildId ?: computeBuildId(containerId)
232226
this.buildTemplate = opts.buildTemplate
233227
this.noEmail = opts.noEmail as boolean
234-
this.multiPlatform = opts.multiPlatform as boolean
235228
}
236229

237230
static BuildRequest of(Map opts) {
238231
new BuildRequest(opts)
239232
}
240233

234+
BuildRequest withScanId(String scanId) {
235+
return BuildRequest.of(
236+
containerId: this.containerId,
237+
containerFile: this.containerFile,
238+
condaFile: this.condaFile,
239+
workspace: this.workspace,
240+
targetImage: this.targetImage,
241+
identity: this.identity,
242+
platform: this.platform,
243+
cacheRepository: this.cacheRepository,
244+
startTime: this.startTime,
245+
ip: this.ip,
246+
configJson: this.configJson,
247+
offsetId: this.offsetId,
248+
containerConfig: this.containerConfig,
249+
scanId: scanId,
250+
buildContext: this.buildContext,
251+
format: this.format,
252+
maxDuration: this.maxDuration,
253+
compression: this.compression,
254+
buildId: this.buildId,
255+
buildTemplate: this.buildTemplate,
256+
noEmail: this.noEmail
257+
)
258+
}
259+
241260
@Override
242261
String toString() {
243262
return "BuildRequest[containerId=$containerId; targetImage=$targetImage; identity=$identity; dockerFile=${trunc(containerFile)}; condaFile=${trunc(condaFile)}; buildId=$buildId, maxDuration=$maxDuration]"

src/main/groovy/io/seqera/wave/service/builder/ManifestAssembler.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ class ManifestAssembler {
118118
]
119119
}
120120
]
121-
return JsonOutput.prettyPrint(JsonOutput.toJson(index))
121+
return JsonOutput.toJson(index)
122122
}
123123

124124
protected void pushManifest(String targetImage, String indexJson, PlatformId identity) {

src/main/groovy/io/seqera/wave/service/builder/MultiBuildRequest.groovy

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,11 @@ class MultiBuildRequest {
6161
PlatformId identity,
6262
Duration maxDuration
6363
) {
64+
assert containerId, "Argument 'containerId' cannot be empty"
6465
assert targetImage, "Argument 'targetImage' cannot be empty"
6566
assert buildId, "Argument 'buildId' cannot be empty"
67+
assert amd64TargetImage, "Argument 'amd64TargetImage' cannot be empty"
68+
assert arm64TargetImage, "Argument 'arm64TargetImage' cannot be empty"
6669

6770
final multiBuildId = ID_PREFIX + LongRndKey.rndHex()
6871
return new MultiBuildRequest(

0 commit comments

Comments
 (0)