diff --git a/src/main/groovy/io/seqera/wave/auth/RegistryAuth.groovy b/src/main/groovy/io/seqera/wave/auth/RegistryAuth.groovy index aa43a4395..67cc240e8 100644 --- a/src/main/groovy/io/seqera/wave/auth/RegistryAuth.groovy +++ b/src/main/groovy/io/seqera/wave/auth/RegistryAuth.groovy @@ -48,13 +48,18 @@ class RegistryAuth { return type==Type.Bearer } - URI getEndpoint() { + URI getEndpoint(String account=null,String repository=null) { if( !realm ) return null final uri = realm.toString() if( uri?.endsWith('.amazonaws.com/') ) return new URI(uri + "v2/") - return new URI(service ? "$uri?service=${service}".toString() : uri) + def query = "service=${service}" + if( account ) + query += "&account=$account" + if( repository ) + query += "&scope=${URLEncoder.encode("repository:$repository:pull",'UTF-8')}" + return new URI(service ? "$uri?$query".toString() : uri) } static RegistryAuth parse(String auth) { diff --git a/src/main/groovy/io/seqera/wave/auth/RegistryAuthService.groovy b/src/main/groovy/io/seqera/wave/auth/RegistryAuthService.groovy index eca481b02..abb5e388b 100644 --- a/src/main/groovy/io/seqera/wave/auth/RegistryAuthService.groovy +++ b/src/main/groovy/io/seqera/wave/auth/RegistryAuthService.groovy @@ -28,7 +28,9 @@ interface RegistryAuthService { /** * Perform a registry login * - * @param registry The registry to login against which e.g. {@code docker.io} + * @param registry + * The registry to login against which e.g. {@code docker.io} or a container + * repository e.g. {@code docker.io/library/ubuntu} * @param user The registry username * @param password The registry password or PAT * @return {@code true} if the login was successful or {@code false} otherwise @@ -38,7 +40,9 @@ interface RegistryAuthService { /** * Check if the provided credentials are valid * - * @param registry The registry to check the credentials which e.g. {@code docker.io} + * @param registry + * The registry to check the credentials which e.g. {@code docker.io} or a container + * repository e.g. {@code docker.io/library/ubuntu} * @param user The registry username * @param password The registry password or PAT * @return {@code true} if the login was successful or {@code false} otherwise diff --git a/src/main/groovy/io/seqera/wave/auth/RegistryAuthServiceImpl.groovy b/src/main/groovy/io/seqera/wave/auth/RegistryAuthServiceImpl.groovy index bdc7fe6da..af142f7cc 100644 --- a/src/main/groovy/io/seqera/wave/auth/RegistryAuthServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/auth/RegistryAuthServiceImpl.groovy @@ -36,6 +36,7 @@ import groovy.transform.ToString import groovy.util.logging.Slf4j import io.seqera.wave.configuration.HttpClientConfig import io.seqera.wave.http.HttpClientFactory +import io.seqera.wave.model.ContainerCoordinates import io.seqera.wave.util.RegHelper import io.seqera.wave.util.Retryable import io.seqera.wave.util.StringUtils @@ -114,7 +115,8 @@ class RegistryAuthServiceImpl implements RegistryAuthService { /** * Implements container registry login * - * @param registryName The registry name e.g. docker.io or quay.io + * @param registryName + * The registry name e.g. docker.io or quay.io or a repository name * @param username The registry username * @param password The registry password * @return {@code true} if the login was successful or {@code false} otherwise @@ -125,18 +127,20 @@ class RegistryAuthServiceImpl implements RegistryAuthService { if( !registryName ) registryName = DOCKER_IO + final target = TargetInfo.parse(registryName) + // 1. look up the registry authorisation info for the given registry name - final registry = lookupService.lookup(registryName) - log.debug "Registry '$registryName' => auth: $registry" + final registry = lookupService.lookup(target.registry) + log.debug "Registry '$target.registry' => auth: $registry" // 2. get the registry credentials // this is needed because some services e.g. AWS ECR requires the use of temporary tokens - final creds = credentialsFactory.create(registryName, username, password) + final creds = credentialsFactory.create(target.registry, username, password) // 3. make a request against the authorization "realm" service using basic // credentials to get the login token final basic = "${creds.username}:${creds.password}".bytes.encodeBase64() - final endpoint = registry.auth.endpoint + final endpoint = registry.auth.getEndpoint() HttpRequest request = HttpRequest.newBuilder() .uri(endpoint) .GET() @@ -285,10 +289,28 @@ class RegistryAuthServiceImpl implements RegistryAuthService { tokenStore.remove(getStableKey(key)) } + @Canonical + static class TargetInfo { + String registry + String repository + + static TargetInfo parse(String registryOrRepository) { + assert registryOrRepository, "Missing 'registryOrRepository' argument" + if( registryOrRepository.contains('/') ) { + final coords = ContainerCoordinates.parse(registryOrRepository) + return new TargetInfo(coords.getRegistry(), coords.getImage()) + } + else { + return new TargetInfo(registryOrRepository) + } + } + } + /** * Invalidate all cached authorization tokens */ private static String getStableKey(CacheKey key) { return "key-" + key.stableKey() } + } diff --git a/src/main/groovy/io/seqera/wave/auth/RegistryCredentialsProviderImpl.groovy b/src/main/groovy/io/seqera/wave/auth/RegistryCredentialsProviderImpl.groovy index e422eceed..fecfe0c31 100644 --- a/src/main/groovy/io/seqera/wave/auth/RegistryCredentialsProviderImpl.groovy +++ b/src/main/groovy/io/seqera/wave/auth/RegistryCredentialsProviderImpl.groovy @@ -21,6 +21,7 @@ package io.seqera.wave.auth import groovy.transform.CompileStatic import groovy.util.logging.Slf4j + import io.micronaut.core.annotation.Nullable import io.seqera.wave.configuration.BuildConfig import io.seqera.wave.core.ContainerPath @@ -118,14 +119,13 @@ class RegistryCredentialsProviderImpl implements RegistryCredentialsProvider { // for a repo having the same registry host if( container.sameRegistry(buildConfig.defaultBuildRepository) || container.sameRegistry(buildConfig.defaultCacheRepository) || container.sameRegistry(buildConfig.defaultPublicRepository) ) return getDefaultCredentials(container) - - return getUserCredentials0(container.registry, identity) + return getUserCredentials0(container, identity) } - protected RegistryCredentials getUserCredentials0(String registry, PlatformId identity) { - final keys = credentialsService.findRegistryCreds(registry, identity) + protected RegistryCredentials getUserCredentials0(ContainerPath container, PlatformId identity) { + final keys = credentialsService.findRegistryCreds(container, identity) final result = keys - ? credentialsFactory.create(registry, keys.userName, keys.password) + ? credentialsFactory.create(container.registry, keys.userName, keys.password) // create a missing credentials class with a unique key (the access token) because even when // no credentials are provided a registry auth token token can be associated to this user : new MissingCredentials(identity.accessToken) diff --git a/src/main/groovy/io/seqera/wave/auth/RegistryLookupService.groovy b/src/main/groovy/io/seqera/wave/auth/RegistryLookupService.groovy index d03a0d39c..1be2a3cc7 100644 --- a/src/main/groovy/io/seqera/wave/auth/RegistryLookupService.groovy +++ b/src/main/groovy/io/seqera/wave/auth/RegistryLookupService.groovy @@ -30,7 +30,7 @@ interface RegistryLookupService { * auth endpoint * * @param registry - * The registry name e.g. {@code docker.io} or {@code quay.io} + * The registry name e.g. {@code docker.io} or {@code quay.io} * @return The corresponding {@link RegistryAuth} object holding the realm URI and service info, * or {@code null} if nothing is found */ diff --git a/src/main/groovy/io/seqera/wave/auth/RegistryLookupServiceImpl.groovy b/src/main/groovy/io/seqera/wave/auth/RegistryLookupServiceImpl.groovy index 02781c8ca..0b20ffb99 100644 --- a/src/main/groovy/io/seqera/wave/auth/RegistryLookupServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/auth/RegistryLookupServiceImpl.groovy @@ -130,7 +130,7 @@ class RegistryLookupServiceImpl implements RegistryLookupService { * @return the corresponding registry endpoint uri */ protected URI registryEndpoint(String registry) { - def result = registry ?: DOCKER_IO + String result = registry ?: DOCKER_IO if( result==DOCKER_IO ) result = DOCKER_REGISTRY_1 if( !result.startsWith('http://') && !result.startsWith('https://') ) diff --git a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy index 631d0bcc8..adad44f8c 100644 --- a/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ValidateController.groovy @@ -25,6 +25,7 @@ import io.micronaut.http.annotation.Post import io.micronaut.scheduling.TaskExecutors import io.micronaut.scheduling.annotation.ExecuteOn import io.seqera.wave.auth.RegistryAuthService +import io.seqera.wave.exchange.ValidateRegistryCredsRequest import jakarta.inject.Inject import reactor.core.publisher.Mono diff --git a/src/main/groovy/io/seqera/wave/controller/ValidateRegistryCredsRequest.groovy b/src/main/groovy/io/seqera/wave/exchange/ValidateRegistryCredsRequest.groovy similarity index 73% rename from src/main/groovy/io/seqera/wave/controller/ValidateRegistryCredsRequest.groovy rename to src/main/groovy/io/seqera/wave/exchange/ValidateRegistryCredsRequest.groovy index db465ef0a..132d37612 100644 --- a/src/main/groovy/io/seqera/wave/controller/ValidateRegistryCredsRequest.groovy +++ b/src/main/groovy/io/seqera/wave/exchange/ValidateRegistryCredsRequest.groovy @@ -16,19 +16,34 @@ * along with this program. If not, see . */ -package io.seqera.wave.controller +package io.seqera.wave.exchange -import io.micronaut.core.annotation.Nullable import javax.validation.constraints.NotBlank import io.micronaut.core.annotation.Introspected +/** + * Request object to valide a container registry credentials + */ @Introspected class ValidateRegistryCredsRequest { + + /** + * The registry user name + */ @NotBlank String userName + + /** + * The registry password + */ @NotBlank String password + + /** + * The registry name e.g. @{code docker.io} or a registry including the repository + * name e.g. {@code docker.io/user/repository} (without tag extension) + */ @NotBlank String registry } diff --git a/src/main/groovy/io/seqera/wave/service/CredentialServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/CredentialServiceImpl.groovy index 8af268b0a..351428b6d 100644 --- a/src/main/groovy/io/seqera/wave/service/CredentialServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/CredentialServiceImpl.groovy @@ -22,8 +22,10 @@ import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.seqera.tower.crypto.AsymmetricCipher import io.seqera.tower.crypto.EncryptedPacket +import io.seqera.wave.core.ContainerPath import io.seqera.wave.service.aws.AwsEcrService import io.seqera.wave.service.pairing.PairingService +import io.seqera.wave.tower.client.CredentialsDescription import io.seqera.wave.tower.PlatformId import io.seqera.wave.tower.auth.JwtAuth import io.seqera.wave.tower.client.CredentialsDescription @@ -31,6 +33,7 @@ import io.seqera.wave.tower.client.TowerClient import jakarta.inject.Inject import jakarta.inject.Singleton import static io.seqera.wave.WaveDefault.DOCKER_IO + /** * Define operations to access container registry credentials from Tower * @@ -48,7 +51,7 @@ class CredentialServiceImpl implements CredentialsService { private PairingService keyService @Override - ContainerRegistryKeys findRegistryCreds(String registryName, PlatformId identity) { + ContainerRegistryKeys findRegistryCreds(ContainerPath container, PlatformId identity) { if (!identity.userId) throw new IllegalArgumentException("Missing userId parameter") if (!identity.accessToken) @@ -77,20 +80,18 @@ class CredentialServiceImpl implements CredentialsService { // the ones associated with docker.io. // This cannot be implemented at the moment since, in tower, container registry // credentials are associated to the whole registry - final matchingRegistryName = registryName ?: DOCKER_IO - def creds = all.find { - it.provider == 'container-reg' && (it.registry ?: DOCKER_IO) == matchingRegistryName - } - if (!creds && identity.workflowId && AwsEcrService.isEcrHost(registryName) ) { + final repo = container.repository ?: DOCKER_IO + def creds = findBestMatchingCreds(repo, all) + if (!creds && identity.workflowId && AwsEcrService.isEcrHost(container.registry) ) { creds = findComputeCreds(identity) } if (!creds) { - log.debug "No credentials matching criteria registryName=$registryName; userId=$identity.userId; workspaceId=$identity.workspaceId; workflowId=${identity.workflowId}; endpoint=$identity.towerEndpoint" + log.debug "No credentials matching criteria registryName=$container.registry; userId=$identity.userId; workspaceId=$identity.workspaceId; workflowId=${identity.workflowId}; endpoint=$identity.towerEndpoint" return null } // log for debugging purposes - log.debug "Credentials matching criteria registryName=$registryName; userId=$identity.userId; workspaceId=$identity.workspaceId; endpoint=$identity.towerEndpoint => $creds" + log.debug "Credentials matching criteria registryName=$container.registry; userId=$identity.userId; workspaceId=$identity.workspaceId; endpoint=$identity.towerEndpoint => $creds" // now fetch the encrypted key final encryptedCredentials = towerClient.fetchEncryptedCredentials(identity.towerEndpoint, JwtAuth.of(identity), creds.id, pairing.pairingId, identity.workspaceId).get() final privateKey = pairing.privateKey @@ -98,6 +99,54 @@ class CredentialServiceImpl implements CredentialsService { return parsePayload(credentials) } + protected CredentialsDescription findBestMatchingCreds(String target, List all) { + assert target, "Missing 'target' container repository" + // take all container registry credentials + final creds = all + .findAll(it-> it.provider=='container-reg' ) + + // try to find an exact match + final match = creds.find(it-> it.registry==target ) + if( match ) + return match + + // find the longest matching repository + creds.inject((CredentialsDescription)null) { best, it-> matchingLongest(target,best,it)} + } + + protected CredentialsDescription matchingLongest(String target, CredentialsDescription best, CredentialsDescription candidate) { + final a = best ? matchingScore(target, best.registry) : 0 + final b = matchingScore(target, candidate.registry) + return a >= b ? best : candidate + } + + /** + * Return the longest matching path length of two container repositories + * + * @param target The target repository to be authenticated + * @param authority The authority repository against which the target repository should be authenticated + * @return An integer greater or equals to zero representing the long the path in the two repositories + */ + protected int matchingScore(String target, String authority) { + if( !authority ) + return 0 + if( !authority.contains('/') && !authority.endsWith('/*') ) + authority += '/*' + if( authority.endsWith('/*') ) { + final len = authority.length()-2 + if( target.startsWith(authority.substring(0,len)) ) + return len + } + else if( target==authority ) { + return target.length() + } + return 0 + } + + protected int repoLen(String repo) { + repo ? repo.tokenize('/').size() : 0 + } + CredentialsDescription findComputeCreds(PlatformId identity) { try { return findComputeCreds0(identity) diff --git a/src/main/groovy/io/seqera/wave/service/CredentialsService.groovy b/src/main/groovy/io/seqera/wave/service/CredentialsService.groovy index 7712c98fc..2515989fa 100644 --- a/src/main/groovy/io/seqera/wave/service/CredentialsService.groovy +++ b/src/main/groovy/io/seqera/wave/service/CredentialsService.groovy @@ -18,8 +18,8 @@ package io.seqera.wave.service +import io.seqera.wave.core.ContainerPath import io.seqera.wave.tower.PlatformId - /** * Declare operations to access container registry credentials from Tower * @@ -27,6 +27,21 @@ import io.seqera.wave.tower.PlatformId */ interface CredentialsService { - ContainerRegistryKeys findRegistryCreds(String registryName, PlatformId identity) - + /** + * + * @param container + * The container for which the registry credentials should be retrieved + * @param userId + * The unique ID of the Tower user + * @param workspaceId + * The unique ID of the Tower workspace + * @param towerToken + * The Tower access token + * @param towerEndpoint + * The Tower endpoint + * @return + * The container registry credentials to be used to authenticate the specified container registry or repository + * or {@code null} if no match is found + */ + ContainerRegistryKeys findRegistryCreds(ContainerPath container, PlatformId identity) } diff --git a/src/main/groovy/io/seqera/wave/tower/client/CredentialsDescription.groovy b/src/main/groovy/io/seqera/wave/tower/client/CredentialsDescription.groovy index 175ed645c..3f866512f 100644 --- a/src/main/groovy/io/seqera/wave/tower/client/CredentialsDescription.groovy +++ b/src/main/groovy/io/seqera/wave/tower/client/CredentialsDescription.groovy @@ -27,8 +27,21 @@ import io.seqera.wave.WaveDefault @ToString(includePackage = false, includeNames = true) class CredentialsDescription { + /** + * The credential record unique ID + */ String id + + /** + * The credentials provider as defined in tower. This must be {@code container-reg} for container registry + * credentials + */ String provider + + /** + * The target container registry e.g. {@code docker.io}, {@code quay.io}. Note this can also specify a full or + * partial container repository name e.g. {@code docker.io/user/foo} + */ String registry @JsonProperty("keys") diff --git a/src/test/groovy/io/seqera/wave/auth/RegistryAuthServiceTest.groovy b/src/test/groovy/io/seqera/wave/auth/RegistryAuthServiceTest.groovy index 7cbb097e7..9b166f1b1 100644 --- a/src/test/groovy/io/seqera/wave/auth/RegistryAuthServiceTest.groovy +++ b/src/test/groovy/io/seqera/wave/auth/RegistryAuthServiceTest.groovy @@ -77,67 +77,72 @@ class RegistryAuthServiceTest extends Specification implements SecureDockerRegis void 'test valid login'() { given: - String uri = getTestRegistryUrl(REGISTRY_URL) + String registry = getTestRegistryName(REGISTRY) when: - boolean logged = loginService.login(uri, USER, PWD) + boolean logged = loginService.login(registry, USER, PWD) then: logged == VALID where: - USER | PWD | REGISTRY_URL | VALID - 'test' | 'test' | 'localhost' | true - 'nope' | 'yepes' | 'localhost' | false - dockerUsername | dockerPassword | "https://registry-1.docker.io" | true - 'nope' | 'yepes' | "https://registry-1.docker.io" | false - quayUsername | quayPassword | "https://quay.io" | true - 'nope' | 'yepes' | "https://quay.io" | false + USER | PWD | REGISTRY | VALID + 'test' | 'test' | 'localhost' | true + 'nope' | 'yepes' | 'localhost' | false + dockerUsername | dockerPassword | "docker.io" | true + 'nope' | 'yepes' | "docker.io" | false + quayUsername | quayPassword | "quay.io" | true + 'nope' | 'yepes' | "quay.io" | false } @IgnoreIf({!System.getenv('AZURECR_USER')}) void 'test valid azure login'() { given: - def REGISTRY_URL = 'seqeralabs.azurecr.io' + def registry = 'seqeralabs.azurecr.io' expect: - loginService.login(REGISTRY_URL, azureUsername, azurePassword) + loginService.login(registry, azureUsername, azurePassword) } @IgnoreIf({!System.getenv('AWS_ACCESS_KEY_ID')}) void 'test valid aws ecr private'() { given: - String REGISTRY_URL = '195996028523.dkr.ecr.eu-west-1.amazonaws.com' + String registry = '195996028523.dkr.ecr.eu-west-1.amazonaws.com' expect: - loginService.login(REGISTRY_URL, awsEcrUsername, awsEcrPassword) + loginService.login(registry, awsEcrUsername, awsEcrPassword) } @IgnoreIf({!System.getenv('AWS_ACCESS_KEY_ID')}) void 'test valid aws ecr public'() { given: - String REGISTRY_URL = 'public.ecr.aws' + String registry = 'public.ecr.aws' expect: - loginService.login(REGISTRY_URL, awsEcrUsername, awsEcrPassword) + loginService.login(registry, awsEcrUsername, awsEcrPassword) } void 'test containerService valid login'() { given: - String uri = getTestRegistryUrl(REGISTRY_URL) + String registry = getTestRegistryName(REGISTRY) when: - boolean logged = loginService.validateUser(uri, USER, PWD) + boolean logged = loginService.validateUser(registry, USER, PWD) then: logged == VALID where: - USER | PWD | REGISTRY_URL | VALID - 'test' | 'test' | 'localhost' | true - 'nope' | 'yepes' | 'localhost' | false - dockerUsername | dockerPassword | "https://registry-1.docker.io" | true - 'nope' | 'yepes' | "https://registry-1.docker.io" | false - quayUsername | quayPassword | "https://quay.io" | true - 'nope' | 'yepes' | "https://quay.io" | false + USER | PWD | REGISTRY | VALID + 'test' | 'test' | 'localhost' | true + 'nope' | 'yepes' | 'localhost' | false + dockerUsername | dockerPassword | "docker.io" | true + 'nope' | 'yepes' | "docker.io" | false + quayUsername | quayPassword | "quay.io" | true + 'nope' | 'yepes' | "quay.io" | false + and: + dockerUsername | dockerPassword | "docker.io/pditommaso/wave-tests" | true + dockerUsername | dockerPassword | "docker.io/pditommaso" | true + // the following should fail + //dockerUsername | dockerPassword | "registry-1.docker.io/unknown/repo" | false } @Ignore @@ -199,7 +204,7 @@ class RegistryAuthServiceTest extends Specification implements SecureDockerRegis def c5 = new RegistryAuthServiceImpl.CacheKey(i1, a1, k3) expect: - c1.stableKey() == '23476a51c7b6216a' + c1.stableKey() == 'c234dc4c210c6612' c1.stableKey() == c2.stableKey() c1.stableKey() == c3.stableKey() and: diff --git a/src/test/groovy/io/seqera/wave/auth/RegistryCredentialsProviderTest.groovy b/src/test/groovy/io/seqera/wave/auth/RegistryCredentialsProviderTest.groovy index 2c34a9ce8..4a165c9de 100644 --- a/src/test/groovy/io/seqera/wave/auth/RegistryCredentialsProviderTest.groovy +++ b/src/test/groovy/io/seqera/wave/auth/RegistryCredentialsProviderTest.groovy @@ -25,6 +25,7 @@ import spock.lang.Specification import io.micronaut.context.annotation.Value import io.micronaut.core.annotation.Nullable import io.micronaut.test.extensions.spock.annotation.MicronautTest +import io.seqera.wave.model.ContainerCoordinates import io.seqera.wave.service.ContainerRegistryKeys import io.seqera.wave.service.CredentialsService import io.seqera.wave.service.aws.AwsEcrService @@ -101,7 +102,7 @@ class RegistryCredentialsProviderTest extends Specification { def 'should get credentials from user' () { given: - def REGISTRY = 'foo' + def CONTAINER = ContainerCoordinates.parse('docker.io/foo') def USER_ID = 100 def WORKSPACE_ID = 200 def TOWER_TOKEN = "token" @@ -114,9 +115,10 @@ class RegistryCredentialsProviderTest extends Specification { and: def identity = new PlatformId(new User(id:USER_ID), WORKSPACE_ID, TOWER_TOKEN, TOWER_ENDPOINT) when: - def result = provider.getUserCredentials0(REGISTRY, identity) + def result = provider.getUserCredentials0(CONTAINER, identity) + then: - 1 * credentialService.findRegistryCreds(REGISTRY, identity) >> new ContainerRegistryKeys(userName:'usr1',password:'pwd2',registry:REGISTRY) + 1 * credentialService.findRegistryCreds(CONTAINER, identity) >> new ContainerRegistryKeys(userName:'usr1',password:'pwd2',registry:CONTAINER) and: result.getUsername() == 'usr1' result.getPassword() == 'pwd2' diff --git a/src/test/groovy/io/seqera/wave/auth/RegistryLookupServiceTest.groovy b/src/test/groovy/io/seqera/wave/auth/RegistryLookupServiceTest.groovy index 65f4525da..3805c8c93 100644 --- a/src/test/groovy/io/seqera/wave/auth/RegistryLookupServiceTest.groovy +++ b/src/test/groovy/io/seqera/wave/auth/RegistryLookupServiceTest.groovy @@ -73,6 +73,8 @@ class RegistryLookupServiceTest extends Specification { 'http://foo.com' | 'http://foo.com/v2/' 'http://foo.com/v2' | 'http://foo.com/v2/' 'http://foo.com/v2/'| 'http://foo.com/v2/' + 'localhost' | 'http://localhost/v2/' + 'localhost:8000' | 'http://localhost:8000/v2/' } } diff --git a/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy index 5f3cad31f..85d28088a 100644 --- a/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ValidateCredsControllerTest.groovy @@ -90,7 +90,7 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR def req = [ userName:'test', password:'test', - registry: getTestRegistryUrl('test') ] + registry: getTestRegistryName('test') ] and: HttpRequest request = HttpRequest.POST("/validate-creds", req) when: @@ -106,7 +106,7 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR def req = [ userName: USER, password: PWD, - registry: getTestRegistryUrl(REGISTRY_URL) + registry: getTestRegistryName(REGISTRY) ] HttpRequest request = HttpRequest.POST("/validate-creds", req) when: @@ -118,13 +118,13 @@ class ValidateCredsControllerTest extends Specification implements SecureDockerR response.body() == VALID where: - USER | PWD | REGISTRY_URL | VALID - 'test' | 'test' | 'test' | true - 'nope' | 'yepes' | 'test' | false - dockerUsername | dockerPassword | "https://registry-1.docker.io" | true - 'nope' | 'yepes' | "https://registry-1.docker.io" | false - quayUsername | quayPassword | "https://quay.io" | true - 'nope' | 'yepes' | "https://quay.io" | false - 'test' | 'test' | 'test' | true + USER | PWD | REGISTRY | VALID + 'test' | 'test' | 'test' | true + 'nope' | 'yepes' | 'test' | false + dockerUsername | dockerPassword | "registry-1.docker.io" | true + 'nope' | 'yepes' | "registry-1.docker.io" | false + quayUsername | quayPassword | "quay.io" | true + 'nope' | 'yepes' | "quay.io" | false + 'test' | 'test' | 'test' | true } } diff --git a/src/test/groovy/io/seqera/wave/service/CredentialsServiceTest.groovy b/src/test/groovy/io/seqera/wave/service/CredentialsServiceTest.groovy index a2babb87d..e3ddba736 100644 --- a/src/test/groovy/io/seqera/wave/service/CredentialsServiceTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/CredentialsServiceTest.groovy @@ -19,6 +19,7 @@ package io.seqera.wave.service import spock.lang.Specification +import spock.lang.Unroll import java.security.PublicKey import java.time.Duration @@ -28,6 +29,8 @@ import java.util.concurrent.CompletableFuture import io.micronaut.test.annotation.MockBean import io.micronaut.test.extensions.spock.annotation.MicronautTest import io.seqera.tower.crypto.AsymmetricCipher +import io.seqera.wave.core.ContainerPath +import io.seqera.wave.model.ContainerCoordinates import io.seqera.wave.service.pairing.PairingRecord import io.seqera.wave.service.pairing.PairingService import io.seqera.wave.tower.PlatformId @@ -101,7 +104,8 @@ class CredentialsServiceTest extends Specification { def auth = JwtAuth.of(identity) when: 'look those registry credentials from tower' - def credentials = credentialsService.findRegistryCreds("quay.io",identity) + def container = ContainerCoordinates.parse("quay.io/foo") + def credentials = credentialsService.findRegistryCreds(container, identity) then: 'the registered key is fetched correctly from the security service' 1 * securityService.getPairingRecord(PairingService.TOWER_SERVICE, towerEndpoint) >> keyRecord @@ -124,9 +128,10 @@ class CredentialsServiceTest extends Specification { def 'should fail if keys where not registered for the tower endpoint'() { given: + def container = ContainerCoordinates.parse('quay.io/foo') def identity = new PlatformId(new User(id:10), 10,"token",'endpoint') when: - credentialsService.findRegistryCreds('quay.io',identity) + credentialsService.findRegistryCreds(container,identity) then: 'the security service does not have the key for the hostname' 1 * securityService.getPairingRecord(PairingService.TOWER_SERVICE,'endpoint') >> null @@ -137,10 +142,12 @@ class CredentialsServiceTest extends Specification { def 'should return no registry credentials if the user has no credentials in tower' () { given: + def container = ContainerCoordinates.parse('quay.io/foo') def identity = new PlatformId(new User(id:10), 10,"token",'tower.io') def auth = JwtAuth.of(identity) when: - def credentials = credentialsService.findRegistryCreds('quay.io', identity) + def credentials = credentialsService.findRegistryCreds(container, identity) + then: 'a key is found' 1 * securityService.getPairingRecord(PairingService.TOWER_SERVICE, 'tower.io') >> new PairingRecord( pairingId: 'a-key-id', @@ -173,7 +180,8 @@ class CredentialsServiceTest extends Specification { def auth = JwtAuth.of(identity) when: - def credentials = credentialsService.findRegistryCreds('quay.io', identity) + def container = ContainerCoordinates.parse('quay.io/foo') + def credentials = credentialsService.findRegistryCreds(container, identity) then: 'a key is found' 1 * securityService.getPairingRecord(PairingService.TOWER_SERVICE, 'tower.io') >> new PairingRecord( @@ -208,6 +216,48 @@ class CredentialsServiceTest extends Specification { keys.password == 'you' } + def'should find the best registry match with exact match'(){ + given: + def svc = new CredentialServiceImpl() + and: + def target = "host.com/foo/bar" + def choices = [ + new CredentialsDescription(registry:"host.com", provider: 'container-reg'), + new CredentialsDescription(registry:"host.com/foo",provider: 'container-reg'), + new CredentialsDescription(registry:"host.com/foo/bar", provider:'container-reg'), + new CredentialsDescription(registry:"host.com/foo/bar", provider:'something-else'), + new CredentialsDescription(registry:"host.com/foo/bar/baz",provider: 'container-reg') ] + + when: + def match = svc.findBestMatchingCreds(target, choices) + + then: + match.registry == "host.com/foo/bar" + match.provider == "container-reg" + } + + def'should find the best registry match with partial match'(){ + given: + def svc = new CredentialServiceImpl() + def target = TARGET + def choices = CHOICES.tokenize(' ').collect(it-> new CredentialsDescription(registry: it, provider: 'container-reg') ) + + when: + def match = svc.findBestMatchingCreds(target, choices) + + then: + match.registry == EXPECTED + + where: + TARGET | EXPECTED | CHOICES + "host.com" | 'host.com' | 'host.com host.com/foo host.com/foo/* host.com/foo/* host.com/fooo/* host.com/foo/bar/baz/*' + "host.com/fo" | 'host.com' | 'host.com host.com/foo host.com/foo/* host.com/foo/* host.com/fooo/* host.com/foo/bar/baz/*' + "host.com/bar" | 'host.com' | 'host.com host.com/foo host.com/foo/* host.com/foo/* host.com/fooo/* host.com/foo/bar/baz/*' + "host.com/foo" | 'host.com/foo' | 'host.com host.com/foo host.com/foo/* host.com/foo/* host.com/fooo/* host.com/foo/bar/baz/*' + "host.com/foo/bar" | 'host.com/foo/*' | 'host.com host.com/foo host.com/foo/* host.com/foo/* host.com/fooo/* host.com/foo/bar/baz/*' + "host.com/foo/bar/baz" | 'host.com/foo/bar/baz/*' | 'host.com host.com/foo host.com/foo/* host.com/foo/* host.com/fooo/* host.com/foo/bar/baz/*' + } + def 'should parse aws keys payload' () { given: def svc = new CredentialServiceImpl() @@ -264,9 +314,10 @@ class CredentialsServiceTest extends Specification { and: def identity = new PlatformId(new User(id:userId), workspaceId,token,towerEndpoint,workflowId) def auth = JwtAuth.of(identity) + def containerPath = ContainerCoordinates.parse("$registryName/foo") when: 'look those registry credentials from tower' - def containerCredentials = credentialsService.findRegistryCreds(registryName,identity) + def containerCredentials = credentialsService.findRegistryCreds(containerPath, identity) then: 'the registered key is fetched correctly from the security service' 1 * securityService.getPairingRecord(PairingService.TOWER_SERVICE, towerEndpoint) >> keyRecord @@ -294,4 +345,51 @@ class CredentialsServiceTest extends Specification { return null } + + @Unroll + def 'should get the repository score' () { + given: + def svc = new CredentialServiceImpl() + + expect: + svc.matchingScore(TARGET, PATTERN) == EXPECTED + + where: + TARGET | PATTERN | EXPECTED + null | null | 0 + 'quay.io' | null | 0 + 'quay.io' | 'docker.io' | 0 + and: + 'quay.io' | 'quay.io' | 'quay.io'.length() + 'quay.io/foo' | 'quay.io' | 'quay.io'.length() + 'quay.io/foo/bar' | 'quay.io' | 'quay.io'.length() + and: + 'quay.io/foo/bar' | 'quay.io/fo' | 0 + 'quay.io/foo/bar' | 'quay.io/fooo' | 0 + 'quay.io/foo/bar' | 'quay.io/*' | 'quay.io'.length() + and: + 'quay.io/foo' | 'quay.io/foo/*' | 'quay.io/foo'.length() + 'quay.io/foo/bar' | 'quay.io/foo/*' | 'quay.io/foo'.length() + and: + // should should return 0 because the "authority" repository has + // a longer name of the target one. Therefore it cannot be used + // to authenticate the target + 'quay.io' | 'quay.io/foo/*' | 0 + 'quay.io/fo/bar' | 'quay.io/foo/*' | 0 + } + + + def 'should return the longest matching repository' () { + given: + def svc = new CredentialServiceImpl() + + expect: + svc.matchingLongest(TARGET, new CredentialsDescription(registry: R1), new CredentialsDescription(registry: R2)).registry == EXPECTED + where: + TARGET | R1 | R2 | EXPECTED + 'docker.io' | 'docker.io' | 'quay.io' | 'docker.io' + 'docker.io/foo' | 'docker.io/foo' | 'docker.io' | 'docker.io/foo' + 'docker.io/foo/bar' | 'docker.io/foo/*' | 'docker.io' | 'docker.io/foo/*' + 'docker.io/foo/bar' | 'docker.io/foo' | 'docker.io' | 'docker.io' + } } diff --git a/src/test/groovy/io/seqera/wave/test/BaseTestContainerRegistry.groovy b/src/test/groovy/io/seqera/wave/test/BaseTestContainerRegistry.groovy index 40ba378b8..2019ac706 100644 --- a/src/test/groovy/io/seqera/wave/test/BaseTestContainerRegistry.groovy +++ b/src/test/groovy/io/seqera/wave/test/BaseTestContainerRegistry.groovy @@ -31,15 +31,19 @@ trait BaseTestContainerRegistry { abstract GenericContainer getTestcontainers() - String getTestRegistryUrl(String registry=null) { + String getTestRegistryName(String registry=null) { if( !registry || registry=='test' || registry=='localhost' ) { int port = testcontainers.firstMappedPort - return "http://$testcontainers.containerIpAddress:$port" + return "${testcontainers.getHost()}:$port" } else return registry } + String getTestRegistryUrl(String registry=null) { + return "http://" + getTestRegistryName(registry) + } + RegistryInfo getLocalTestRegistryInfo() { final uri = new URI(getTestRegistryUrl()) new RegistryInfo('test', uri, new RegistryAuth(uri, null, RegistryAuth.Type.Basic))