From c853a978a3c21c5f7631628bdae4e31f2cea2e2c Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Fri, 19 Dec 2025 17:26:25 +0100 Subject: [PATCH 1/3] using ContainerPlatform.DEFAULT for selector label generation Signed-off-by: munishchouhan --- .../wave/service/mirror/strategy/KubeMirrorStrategy.groovy | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategy.groovy b/src/main/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategy.groovy index 5dab0f42b..7f21584a5 100644 --- a/src/main/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategy.groovy +++ b/src/main/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategy.groovy @@ -29,6 +29,7 @@ import io.micronaut.context.annotation.Requires import io.micronaut.core.annotation.Nullable import io.seqera.wave.configuration.MirrorConfig import io.seqera.wave.configuration.MirrorEnabled +import io.seqera.wave.core.ContainerPlatform import io.seqera.wave.exception.BadRequestException import io.seqera.wave.service.k8s.K8sService import io.seqera.wave.service.mirror.MirrorRequest @@ -63,7 +64,7 @@ class KubeMirrorStrategy extends MirrorStrategy { void mirrorJob(String jobName, MirrorRequest request) { // docker auth json file final Path configFile = request.authJson ? request.workDir.resolve('config.json') : null - final selector = getSelectorLabel(request.platform, nodeSelectorMap) + final selector = getSelectorLabel(ContainerPlatform.DEFAULT, nodeSelectorMap) try { k8sService.launchMirrorJob( From 6204d0f55245e75329523db9dc67209c09f48173 Mon Sep 17 00:00:00 2001 From: munishchouhan Date: Fri, 19 Dec 2025 21:36:09 +0100 Subject: [PATCH 2/3] added tests Signed-off-by: munishchouhan --- .../mirror/strategy/KubeMirrorStrategy.groovy | 4 +- .../strategy/KubeMirrorStrategyTest.groovy | 86 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/test/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategyTest.groovy diff --git a/src/main/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategy.groovy b/src/main/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategy.groovy index 7f21584a5..3bee69d8f 100644 --- a/src/main/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategy.groovy +++ b/src/main/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategy.groovy @@ -64,7 +64,9 @@ class KubeMirrorStrategy extends MirrorStrategy { void mirrorJob(String jobName, MirrorRequest request) { // docker auth json file final Path configFile = request.authJson ? request.workDir.resolve('config.json') : null - final selector = getSelectorLabel(ContainerPlatform.DEFAULT, nodeSelectorMap) + final selector = getSelectorLabel( + request.platform ?: ContainerPlatform.DEFAULT, + nodeSelectorMap) try { k8sService.launchMirrorJob( diff --git a/src/test/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategyTest.groovy b/src/test/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategyTest.groovy new file mode 100644 index 000000000..8ea48d5fd --- /dev/null +++ b/src/test/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategyTest.groovy @@ -0,0 +1,86 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.service.mirror.strategy + +import spock.lang.Specification + +import java.nio.file.Path + +import io.seqera.wave.configuration.MirrorConfig +import io.seqera.wave.core.ContainerPlatform +import io.seqera.wave.service.k8s.K8sService +import io.seqera.wave.service.mirror.MirrorRequest + +/** + * + * @author Munish Chouhan + */ +class KubeMirrorStrategyTest extends Specification { + def "should create node selector with platform and merge with configured node selector"() { + given: + def k8sService = Mock(K8sService) + def strategy = new KubeMirrorStrategy( + config: Mock(MirrorConfig) { + getSkopeoImage() >> 'quay.io/skopeo/stable:latest' + }, + k8sService: k8sService, + nodeSelectorMap: ['linux/amd64': 'service=wave-build', + 'linux/arm64': 'service=wave-build-arm64'] + ) + def request = Mock(MirrorRequest) { + getWorkDir() >> Path.of('/tmp/work') + getAuthJson() >> null + getPlatform() >> ContainerPlatform.of('linux/arm64') + } + + when: + strategy.mirrorJob('job-123', request) + + then: + 1 * k8sService.launchMirrorJob('job-123', 'quay.io/skopeo/stable:latest', _, Path.of('/tmp/work'), null, _, + ['service':'wave-build-arm64']) + + } + + def "should create node selector with default platform when platform is not specified"() { + given: + def k8sService = Mock(K8sService) + def strategy = new KubeMirrorStrategy( + config: Mock(MirrorConfig) { + getSkopeoImage() >> 'quay.io/skopeo/stable:latest' + }, + k8sService: k8sService, + nodeSelectorMap: ['linux/amd64': 'service=wave-build', + 'linux/arm64': 'service=wave-build-arm64'] + ) + def request = Mock(MirrorRequest) { + getWorkDir() >> Path.of('/tmp/work') + getAuthJson() >> null + getPlatform() >> null + } + + when: + strategy.mirrorJob('job-456', request) + + then: + 1 * k8sService.launchMirrorJob('job-456', 'quay.io/skopeo/stable:latest', _, Path.of('/tmp/work'), null, _, + ['service':'wave-build']) + } + +} From 9a793c6e13404c3250764428adea36da5db08ac6 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Thu, 8 Jan 2026 17:22:11 +0700 Subject: [PATCH 3/3] Add getNoArchSelector method for architecture-independent workloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add K8sHelper.getNoArchSelector() to get node selector for 'noarch' key - Update KubeMirrorStrategy and KubeTransferStrategy to use noarch selector - Update tests to use noarch node selector 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../blob/impl/KubeTransferStrategy.groovy | 9 ++------ .../mirror/strategy/KubeMirrorStrategy.groovy | 8 ++----- .../io/seqera/wave/util/K8sHelper.groovy | 22 +++++++++++++++++++ .../blob/impl/KubeTransferStrategyTest.groovy | 5 +++-- .../strategy/KubeMirrorStrategyTest.groovy | 16 ++++++-------- .../io/seqera/wave/util/K8sHelperTest.groovy | 13 +++++++++++ 6 files changed, 49 insertions(+), 24 deletions(-) diff --git a/src/main/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategy.groovy b/src/main/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategy.groovy index dd2846bff..237b1d40d 100644 --- a/src/main/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategy.groovy +++ b/src/main/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategy.groovy @@ -18,19 +18,16 @@ package io.seqera.wave.service.blob.impl - import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.micronaut.context.annotation.Property import io.micronaut.context.annotation.Requires import io.micronaut.core.annotation.Nullable import io.seqera.wave.configuration.BlobCacheConfig -import io.seqera.wave.core.ContainerPlatform import io.seqera.wave.service.blob.TransferStrategy import io.seqera.wave.service.k8s.K8sService import jakarta.inject.Inject -import static io.seqera.wave.util.K8sHelper.getSelectorLabel - +import static io.seqera.wave.util.K8sHelper.getNoArchSelector /** * Implements {@link TransferStrategy} that runs s5cmd using a * Kubernetes job @@ -55,9 +52,7 @@ class KubeTransferStrategy implements TransferStrategy { @Override void launchJob(String jobName, List command) { - - final selector = getSelectorLabel(ContainerPlatform.DEFAULT, nodeSelectorMap) - + final selector = getNoArchSelector(nodeSelectorMap) // run the transfer job k8sService.launchTransferJob(jobName, blobConfig.s5Image, command, blobConfig, selector) } diff --git a/src/main/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategy.groovy b/src/main/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategy.groovy index 3bee69d8f..777ea76fc 100644 --- a/src/main/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategy.groovy +++ b/src/main/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategy.groovy @@ -29,14 +29,12 @@ import io.micronaut.context.annotation.Requires import io.micronaut.core.annotation.Nullable import io.seqera.wave.configuration.MirrorConfig import io.seqera.wave.configuration.MirrorEnabled -import io.seqera.wave.core.ContainerPlatform import io.seqera.wave.exception.BadRequestException import io.seqera.wave.service.k8s.K8sService import io.seqera.wave.service.mirror.MirrorRequest import jakarta.inject.Inject import jakarta.inject.Singleton -import static io.seqera.wave.util.K8sHelper.getSelectorLabel - +import static io.seqera.wave.util.K8sHelper.getNoArchSelector /** * Implements a container mirror runner based on Kubernetes * @@ -64,9 +62,7 @@ class KubeMirrorStrategy extends MirrorStrategy { void mirrorJob(String jobName, MirrorRequest request) { // docker auth json file final Path configFile = request.authJson ? request.workDir.resolve('config.json') : null - final selector = getSelectorLabel( - request.platform ?: ContainerPlatform.DEFAULT, - nodeSelectorMap) + final selector = getNoArchSelector(nodeSelectorMap) try { k8sService.launchMirrorJob( diff --git a/src/main/groovy/io/seqera/wave/util/K8sHelper.groovy b/src/main/groovy/io/seqera/wave/util/K8sHelper.groovy index 032709b6d..e11cb2414 100644 --- a/src/main/groovy/io/seqera/wave/util/K8sHelper.groovy +++ b/src/main/groovy/io/seqera/wave/util/K8sHelper.groovy @@ -19,12 +19,14 @@ package io.seqera.wave.util import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j import io.seqera.wave.core.ContainerPlatform import io.seqera.wave.exception.BadRequestException /** * * @author Paolo Di Tommaso */ +@Slf4j @CompileStatic class K8sHelper { @@ -59,6 +61,26 @@ class K8sHelper { throw new BadRequestException("Unsupported container platform '${platform}'") } + /** + * Get the node selector for architecture-independent (noarch) workloads + * + * @param nodeSelectors + * A map that associate the platform architecture with a corresponding node selector label + * @return + * A {@link Map} object representing a kubernetes label to be used as node selector for noarch workloads, + * or an empty map when there's no matching + */ + static Map getNoArchSelector(Map nodeSelectors) { + if( !nodeSelectors ) + return Collections.emptyMap() + final value = nodeSelectors.get('noarch') + if( !value ) { + log.warn("Node selectors are configured but 'noarch' key is missing - available keys: ${nodeSelectors.keySet()}") + return Collections.emptyMap() + } + return toLabelMap(value) + } + /** * Given a label formatted as key=value, return it as a map * diff --git a/src/test/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategyTest.groovy b/src/test/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategyTest.groovy index c7159322a..635fed252 100644 --- a/src/test/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategyTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategyTest.groovy @@ -39,12 +39,13 @@ class KubeTransferStrategyTest extends Specification { BlobCacheConfig blobConfig = new BlobCacheConfig(s5Image: 's5cmd', transferTimeout: Duration.ofSeconds(10), retryAttempts: 3) KubeTransferStrategy strategy = new KubeTransferStrategy(k8sService: k8sService, blobConfig: blobConfig, nodeSelectorMap: [ 'linux/amd64': 'service=wave-build', - 'linux/arm64': 'service=wave-build-arm64' + 'linux/arm64': 'service=wave-build-arm64', + 'noarch': 'service=wave-transfer' ]) def "transfer should start a transferJob"() { given: - final selector = ['service': 'wave-build'] + final selector = ['service': 'wave-transfer'] def info = BlobEntry.create("https://test.com/blobs", "https://test.com/bucket/blobs", null, null) def command = ["transfer", "blob"] final jobName = "job-123" diff --git a/src/test/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategyTest.groovy b/src/test/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategyTest.groovy index 8ea48d5fd..0eb6aff4c 100644 --- a/src/test/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategyTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/mirror/strategy/KubeMirrorStrategyTest.groovy @@ -23,7 +23,6 @@ import spock.lang.Specification import java.nio.file.Path import io.seqera.wave.configuration.MirrorConfig -import io.seqera.wave.core.ContainerPlatform import io.seqera.wave.service.k8s.K8sService import io.seqera.wave.service.mirror.MirrorRequest @@ -32,7 +31,8 @@ import io.seqera.wave.service.mirror.MirrorRequest * @author Munish Chouhan */ class KubeMirrorStrategyTest extends Specification { - def "should create node selector with platform and merge with configured node selector"() { + + def "should use noarch node selector for mirror job"() { given: def k8sService = Mock(K8sService) def strategy = new KubeMirrorStrategy( @@ -41,12 +41,12 @@ class KubeMirrorStrategyTest extends Specification { }, k8sService: k8sService, nodeSelectorMap: ['linux/amd64': 'service=wave-build', - 'linux/arm64': 'service=wave-build-arm64'] + 'linux/arm64': 'service=wave-build-arm64', + 'noarch': 'service=wave-mirror'] ) def request = Mock(MirrorRequest) { getWorkDir() >> Path.of('/tmp/work') getAuthJson() >> null - getPlatform() >> ContainerPlatform.of('linux/arm64') } when: @@ -54,11 +54,10 @@ class KubeMirrorStrategyTest extends Specification { then: 1 * k8sService.launchMirrorJob('job-123', 'quay.io/skopeo/stable:latest', _, Path.of('/tmp/work'), null, _, - ['service':'wave-build-arm64']) - + ['service':'wave-mirror']) } - def "should create node selector with default platform when platform is not specified"() { + def "should use empty selector when noarch is not configured"() { given: def k8sService = Mock(K8sService) def strategy = new KubeMirrorStrategy( @@ -72,7 +71,6 @@ class KubeMirrorStrategyTest extends Specification { def request = Mock(MirrorRequest) { getWorkDir() >> Path.of('/tmp/work') getAuthJson() >> null - getPlatform() >> null } when: @@ -80,7 +78,7 @@ class KubeMirrorStrategyTest extends Specification { then: 1 * k8sService.launchMirrorJob('job-456', 'quay.io/skopeo/stable:latest', _, Path.of('/tmp/work'), null, _, - ['service':'wave-build']) + [:]) } } diff --git a/src/test/groovy/io/seqera/wave/util/K8sHelperTest.groovy b/src/test/groovy/io/seqera/wave/util/K8sHelperTest.groovy index d9507e3e6..83333a078 100644 --- a/src/test/groovy/io/seqera/wave/util/K8sHelperTest.groovy +++ b/src/test/groovy/io/seqera/wave/util/K8sHelperTest.groovy @@ -54,4 +54,17 @@ class K8sHelperTest extends Specification { err.message == "Unsupported container platform 'linux/amd64'" } + def 'should get noarch selector' () { + expect: + K8sHelper.getNoArchSelector(SELECTORS) == EXPECTED + + where: + SELECTORS | EXPECTED + null | [:] + [:] | [:] + ['noarch': 'foo=1'] | ['foo': '1'] + ['amd64': 'bar=2', 'noarch': 'foo=1'] | ['foo': '1'] + ['amd64': 'bar=2', 'arm64': 'baz=3'] | [:] // logs warning + } + }