From 5815702c92007d471eee2fb4ad56f7b27641e469 Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Sun, 22 Mar 2026 14:47:42 +0100 Subject: [PATCH 1/3] added support for multi-arch platform Signed-off-by: munishchouhan --- app/src/main/java/io/seqera/wave/cli/App.java | 53 +++++- .../groovy/io/seqera/wave/cli/AppTest.groovy | 176 ++++++++++++++++++ 2 files changed, 224 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/seqera/wave/cli/App.java b/app/src/main/java/io/seqera/wave/cli/App.java index c7976f9..6e15dc7 100644 --- a/app/src/main/java/io/seqera/wave/cli/App.java +++ b/app/src/main/java/io/seqera/wave/cli/App.java @@ -95,7 +95,13 @@ public class App implements Runnable { private static final boolean isWindows = System.getProperty("os.name").toLowerCase().contains("windows"); private static final String DEFAULT_TOWER_ENDPOINT = "https://api.cloud.seqera.io"; - private static final List VALID_PLATFORMS = List.of("amd64", "x86_64", "linux/amd64", "linux/x86_64", "arm64", "linux/arm64"); + private static final List AMD64_ALIASES = List.of("amd64", "x86_64", "linux/amd64", "linux/x86_64"); + + private static final List ARM64_ALIASES = List.of("arm64", "linux/arm64"); + + private static final String MULTI_ARCH_PLATFORM = "linux/amd64,linux/arm64"; + + private static final String PLATFORM_ALL = "all"; private static final long _1MB = 1024 * 1024; @@ -126,7 +132,7 @@ public class App implements Runnable { @Option(names = {"--freeze", "-F"}, paramLabel = "false", description = "Request a container freeze.") private boolean freeze; - @Option(names = {"--platform"}, paramLabel = "''", description = "Platform to be used for the container build. One of: linux/amd64, linux/arm64.") + @Option(names = {"--platform"}, paramLabel = "''", description = "Platform to be used for the container build. One of: linux/amd64, linux/arm64, linux/amd64,linux/arm64 for multi-arch builds, or 'all'.") private String platform; @Option(names = {"--await"}, paramLabel = "false", arity = "0..1", description = "Await the container build to be available. you can provide a timeout like --await 10m or 2s, by default its 15 minutes.") @@ -420,9 +426,24 @@ protected void validateArgs() { if( dryRun && await != null ) throw new IllegalCliArgumentException("Options --dry-run and --await conflicts each other"); - if( !isEmpty(platform) && !VALID_PLATFORMS.contains(platform) ) - throw new IllegalCliArgumentException(String.format("Unsupported container platform: '%s'", platform)); + if( !isEmpty(platform) ) + validatePlatform(platform); + + } + + protected void validatePlatform(String platform) { + // normalizePlatform validates each platform value via canonicalPlatform + normalizePlatform(platform); + // check multi-arch specific constraints + if( PLATFORM_ALL.equals(platform) || platform.contains(",") ) + validateMultiArch(); + } + private void validateMultiArch() { + if( isEmpty(containerFile) && isEmpty(condaFile) && condaPackages==null && cranPackages==null ) + throw new IllegalCliArgumentException("Multi-arch build requires a container file (--containerfile) or package specification"); + if( singularity ) + throw new IllegalCliArgumentException("Multi-arch build is not compatible with Singularity format"); } protected Client client() { @@ -434,7 +455,7 @@ protected SubmitContainerTokenRequest createRequest() { .withContainerImage(image) .withContainerFile(containerFileBase64()) .withPackages(packagesSpec()) - .withContainerPlatform(platform) + .withContainerPlatform(normalizePlatform(platform)) .withTimestamp(OffsetDateTime.now()) .withBuildRepository(buildRepository) .withCacheRepository(cacheRepository) @@ -456,6 +477,28 @@ protected SubmitContainerTokenRequest createRequest() { ; } + protected String normalizePlatform(String platform) { + if( isEmpty(platform) ) + return null; + if( PLATFORM_ALL.equals(platform) ) + return MULTI_ARCH_PLATFORM; + if( platform.contains(",") ) { + return Arrays.stream(platform.split(",")) + .map(String::trim) + .map(App::canonicalPlatform) + .collect(Collectors.joining(",")); + } + return canonicalPlatform(platform); + } + + private static String canonicalPlatform(String value) { + if( AMD64_ALIASES.contains(value) ) + return "linux/amd64"; + if( ARM64_ALIASES.contains(value) ) + return "linux/arm64"; + throw new IllegalCliArgumentException(String.format("Unsupported container platform: '%s'", value)); + } + BuildCompression compression(BuildCompression.Mode mode) { if( mode==null ) return null; diff --git a/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy b/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy index 30f3e93..57ba912 100644 --- a/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy +++ b/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy @@ -631,6 +631,182 @@ class AppTest extends Specification { e.getMessage() == "Option --mirror and requires the use of a build repository" } + def 'should allow multi-arch platform linux/amd64,linux/arm64 with containerfile' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "linux/amd64,linux/arm64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + + when: + def req = app.createRequest() + then: + req.containerPlatform == 'linux/amd64,linux/arm64' + + cleanup: + folder?.deleteDir() + } + + def 'should normalize shorthand multi-arch platform to canonical form' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "amd64,arm64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + + when: + def req = app.createRequest() + then: + req.containerPlatform == 'linux/amd64,linux/arm64' + + cleanup: + folder?.deleteDir() + } + + def 'should normalize single platform to canonical form' () { + given: + def app = new App() + String[] args = ["-i", "ubuntu:latest", "--platform", "x86_64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + + when: + def req = app.createRequest() + then: + req.containerPlatform == 'linux/amd64' + } + + def 'should allow platform all and map to multi-arch' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "all"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + + when: + def req = app.createRequest() + then: + req.containerPlatform == 'linux/amd64,linux/arm64' + + cleanup: + folder?.deleteDir() + } + + def 'should fail multi-arch with image only and no containerfile' () { + given: + def app = new App() + String[] args = ["-i", "ubuntu:latest", "--platform", "linux/amd64,linux/arm64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + def e = thrown(IllegalCliArgumentException) + e.message == 'Multi-arch build requires a container file (--containerfile) or package specification' + } + + def 'should fail multi-arch all with image only' () { + given: + def app = new App() + String[] args = ["-i", "ubuntu:latest", "--platform", "all"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + def e = thrown(IllegalCliArgumentException) + e.message == 'Multi-arch build requires a container file (--containerfile) or package specification' + } + + def 'should fail multi-arch with singularity' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "linux/amd64,linux/arm64", "--singularity", "--freeze", "--build-repo", "docker.io/foo", "--tower-token", "xyz"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + def e = thrown(IllegalCliArgumentException) + e.message == 'Multi-arch build is not compatible with Singularity format' + + cleanup: + folder?.deleteDir() + } + + def 'should allow multi-arch with conda packages' () { + given: + def app = new App() + String[] args = ["--conda-package", "samtools=1.17", "--platform", "linux/amd64,linux/arm64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + } + + def 'should fail when comma-separated platform contains invalid value' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "linux/amd64,bogus"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + def e = thrown(IllegalCliArgumentException) + e.message == "Unsupported container platform: 'bogus'" + + cleanup: + folder?.deleteDir() + } + @Unroll def 'should check service version'() { given: From 28b3dc4350553913655e652e4d70420b97716f1f Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Mon, 23 Mar 2026 15:52:50 +0100 Subject: [PATCH 2/3] refactored Signed-off-by: munishchouhan --- app/src/main/java/io/seqera/wave/cli/App.java | 28 +++--- .../groovy/io/seqera/wave/cli/AppTest.groovy | 87 +++++++++++++++++-- 2 files changed, 91 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/io/seqera/wave/cli/App.java b/app/src/main/java/io/seqera/wave/cli/App.java index 6e15dc7..b496fb5 100644 --- a/app/src/main/java/io/seqera/wave/cli/App.java +++ b/app/src/main/java/io/seqera/wave/cli/App.java @@ -132,7 +132,7 @@ public class App implements Runnable { @Option(names = {"--freeze", "-F"}, paramLabel = "false", description = "Request a container freeze.") private boolean freeze; - @Option(names = {"--platform"}, paramLabel = "''", description = "Platform to be used for the container build. One of: linux/amd64, linux/arm64, linux/amd64,linux/arm64 for multi-arch builds, or 'all'.") + @Option(names = {"--platform"}, paramLabel = "''", description = "Platform to be used for the container build. One of: linux/amd64, linux/arm64. Use 'all' for multi-arch builds.") private String platform; @Option(names = {"--await"}, paramLabel = "false", arity = "0..1", description = "Await the container build to be available. you can provide a timeout like --await 10m or 2s, by default its 15 minutes.") @@ -426,17 +426,12 @@ protected void validateArgs() { if( dryRun && await != null ) throw new IllegalCliArgumentException("Options --dry-run and --await conflicts each other"); - if( !isEmpty(platform) ) - validatePlatform(platform); - - } + if( !isEmpty(platform) ) { + platform = normalizePlatform(platform); + if( platform.contains(",") ) + validateMultiArch(); + } - protected void validatePlatform(String platform) { - // normalizePlatform validates each platform value via canonicalPlatform - normalizePlatform(platform); - // check multi-arch specific constraints - if( PLATFORM_ALL.equals(platform) || platform.contains(",") ) - validateMultiArch(); } private void validateMultiArch() { @@ -455,7 +450,7 @@ protected SubmitContainerTokenRequest createRequest() { .withContainerImage(image) .withContainerFile(containerFileBase64()) .withPackages(packagesSpec()) - .withContainerPlatform(normalizePlatform(platform)) + .withContainerPlatform(platform) .withTimestamp(OffsetDateTime.now()) .withBuildRepository(buildRepository) .withCacheRepository(cacheRepository) @@ -484,19 +479,20 @@ protected String normalizePlatform(String platform) { return MULTI_ARCH_PLATFORM; if( platform.contains(",") ) { return Arrays.stream(platform.split(",")) - .map(String::trim) .map(App::canonicalPlatform) + .distinct() .collect(Collectors.joining(",")); } return canonicalPlatform(platform); } private static String canonicalPlatform(String value) { - if( AMD64_ALIASES.contains(value) ) + final String v = value.trim(); + if( AMD64_ALIASES.contains(v) ) return "linux/amd64"; - if( ARM64_ALIASES.contains(value) ) + if( ARM64_ALIASES.contains(v) ) return "linux/arm64"; - throw new IllegalCliArgumentException(String.format("Unsupported container platform: '%s'", value)); + throw new IllegalCliArgumentException(String.format("Unsupported container platform: '%s'", v)); } BuildCompression compression(BuildCompression.Mode mode) { diff --git a/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy b/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy index 57ba912..e2c514c 100644 --- a/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy +++ b/app/src/test/groovy/io/seqera/wave/cli/AppTest.groovy @@ -388,16 +388,16 @@ class AppTest extends Specification { and: app.validateArgs() then: - app.@platform == PLATFORM + app.@platform == EXPECTED where: - PLATFORM || _ - 'amd64' || _ - 'x86_64' || _ - 'arm64' || _ - 'linux/amd64' || _ - 'linux/x86_64' || _ - 'linux/arm64' || _ + PLATFORM || EXPECTED + 'amd64' || 'linux/amd64' + 'x86_64' || 'linux/amd64' + 'arm64' || 'linux/arm64' + 'linux/amd64' || 'linux/amd64' + 'linux/x86_64' || 'linux/amd64' + 'linux/arm64' || 'linux/arm64' } @Unroll @@ -786,6 +786,77 @@ class AppTest extends Specification { noExceptionThrown() } + def 'should normalize platform with whitespace in comma-separated values' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "linux/amd64, linux/arm64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + + when: + def req = app.createRequest() + then: + req.containerPlatform == 'linux/amd64,linux/arm64' + + cleanup: + folder?.deleteDir() + } + + def 'should fail platform all with singularity' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "all", "--singularity", "--freeze", "--build-repo", "docker.io/foo", "--tower-token", "xyz"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + def e = thrown(IllegalCliArgumentException) + e.message == 'Multi-arch build is not compatible with Singularity format' + + cleanup: + folder?.deleteDir() + } + + def 'should deduplicate repeated platforms' () { + given: + def folder = Files.createTempDirectory('test') + def containerFile = folder.resolve('Dockerfile') + containerFile.text = 'FROM ubuntu:latest' + and: + def app = new App() + String[] args = ["-f", containerFile.toString(), "--platform", "amd64,amd64"] + + when: + new CommandLine(app).parseArgs(args) + and: + app.validateArgs() + then: + noExceptionThrown() + + when: + def req = app.createRequest() + then: + req.containerPlatform == 'linux/amd64' + + cleanup: + folder?.deleteDir() + } + def 'should fail when comma-separated platform contains invalid value' () { given: def folder = Files.createTempDirectory('test') From b44da555f6b793d02add5c28a43442b7f1282bb8 Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Tue, 24 Mar 2026 16:52:27 +0100 Subject: [PATCH 3/3] dded CANONICAL_AMD64 and CANONICAL_ARM64 constants Signed-off-by: munishchouhan --- app/src/main/java/io/seqera/wave/cli/App.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/seqera/wave/cli/App.java b/app/src/main/java/io/seqera/wave/cli/App.java index b496fb5..ed6cf62 100644 --- a/app/src/main/java/io/seqera/wave/cli/App.java +++ b/app/src/main/java/io/seqera/wave/cli/App.java @@ -95,11 +95,17 @@ public class App implements Runnable { private static final boolean isWindows = System.getProperty("os.name").toLowerCase().contains("windows"); private static final String DEFAULT_TOWER_ENDPOINT = "https://api.cloud.seqera.io"; + private static final List VALID_PLATFORMS = List.of("amd64", "x86_64", "linux/amd64", "linux/x86_64", "arm64", "linux/arm64"); + + private static final String CANONICAL_AMD64 = "linux/amd64"; + + private static final String CANONICAL_ARM64 = "linux/arm64"; + private static final List AMD64_ALIASES = List.of("amd64", "x86_64", "linux/amd64", "linux/x86_64"); private static final List ARM64_ALIASES = List.of("arm64", "linux/arm64"); - private static final String MULTI_ARCH_PLATFORM = "linux/amd64,linux/arm64"; + private static final String MULTI_ARCH_PLATFORM = CANONICAL_AMD64 + "," + CANONICAL_ARM64; private static final String PLATFORM_ALL = "all"; @@ -489,9 +495,9 @@ protected String normalizePlatform(String platform) { private static String canonicalPlatform(String value) { final String v = value.trim(); if( AMD64_ALIASES.contains(v) ) - return "linux/amd64"; + return CANONICAL_AMD64; if( ARM64_ALIASES.contains(v) ) - return "linux/arm64"; + return CANONICAL_ARM64; throw new IllegalCliArgumentException(String.format("Unsupported container platform: '%s'", v)); }