diff --git a/src/main/java/io/codeka/gaia/modules/api/DockerRegistryApi.kt b/src/main/java/io/codeka/gaia/modules/api/DockerRegistryApi.kt new file mode 100644 index 000000000..07c807f4b --- /dev/null +++ b/src/main/java/io/codeka/gaia/modules/api/DockerRegistryApi.kt @@ -0,0 +1,41 @@ +package io.codeka.gaia.modules.api + +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.ParameterizedTypeReference +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Repository +import org.springframework.web.client.RestTemplate + +inline fun typeRef(): ParameterizedTypeReference = object : ParameterizedTypeReference() {} + +@Repository +class DockerRegistryApi constructor( + @Value("\${docker.registry.api.url}") private val dockerRegistryApiUrl: String, + private val restTemplate: RestTemplate) { + + fun findRepositoriesByName(name: String, pageNum: Int = 1, pageSize: Int = 10): List { + val response = restTemplate.exchange( + "$dockerRegistryApiUrl/search/repositories?query=$name&page=$pageNum&page_size=$pageSize", + HttpMethod.GET, + HttpEntity(HttpHeaders()), + typeRef>()) + return if (HttpStatus.OK == response.statusCode && null != response.body) { + response.body!!.results + } else listOf() + } + + fun findTagsByName(name: String, repository: String, pageNum: Int = 1, pageSize: Int = 10): List { + val response = restTemplate.exchange( + "$dockerRegistryApiUrl/repositories/$repository/tags?name=$name&page=$pageNum&page_size=$pageSize", + HttpMethod.GET, + HttpEntity(HttpHeaders()), + typeRef>()) + return if (HttpStatus.OK == response.statusCode && null != response.body) { + response.body!!.results + } else listOf() + } + +} \ No newline at end of file diff --git a/src/main/java/io/codeka/gaia/modules/api/DockerRegistryResponse.kt b/src/main/java/io/codeka/gaia/modules/api/DockerRegistryResponse.kt new file mode 100644 index 000000000..ddafeb6dd --- /dev/null +++ b/src/main/java/io/codeka/gaia/modules/api/DockerRegistryResponse.kt @@ -0,0 +1,13 @@ +package io.codeka.gaia.modules.api + +import com.fasterxml.jackson.annotation.JsonAlias + +sealed class DockerRegistryResponseResult + +data class DockerRegistryResponse(val results: List) + +data class DockerRegistryRepository( + @JsonAlias("repo_name") val name: String, + @JsonAlias("short_description") val description: String) : DockerRegistryResponseResult() + +data class DockerRegistryRepositoryTag(@JsonAlias("name") val name: String) : DockerRegistryResponseResult() \ No newline at end of file diff --git a/src/main/java/io/codeka/gaia/modules/controller/DockerRegistryRestController.kt b/src/main/java/io/codeka/gaia/modules/controller/DockerRegistryRestController.kt new file mode 100644 index 000000000..332fa0213 --- /dev/null +++ b/src/main/java/io/codeka/gaia/modules/controller/DockerRegistryRestController.kt @@ -0,0 +1,25 @@ +package io.codeka.gaia.modules.controller + +import io.codeka.gaia.modules.api.DockerRegistryApi +import org.springframework.security.access.annotation.Secured +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/docker") +@Secured +class DockerRegistryRestController(private val dockerRegistryApi: DockerRegistryApi) { + + @GetMapping("/repositories") + fun listRepositoriesByName(@RequestParam name: String) = this.dockerRegistryApi.findRepositoriesByName(name) + + @GetMapping( + "/repositories/{repository}/tags", + "/repositories/{owner}/{repository}/tags") + fun listTagsByName( + @PathVariable repository: String, + @PathVariable(required = false) owner: String?, + @RequestParam name: String + ) = this.dockerRegistryApi.findTagsByName(name, "${owner ?: "library"}/$repository") + +} + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index bae8ba1e4..f422b2a50 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -19,3 +19,4 @@ gaia.dockerDaemonUrl=unix:///var/run/docker.sock terraform.releases.url=https://releases.hashicorp.com/terraform/ terraform.releases.version.min=0.11.13 +docker.registry.api.url=https://registry.hub.docker.com/v2 diff --git a/src/test/java/io/codeka/gaia/modules/api/DockerRegistryApiTest.kt b/src/test/java/io/codeka/gaia/modules/api/DockerRegistryApiTest.kt new file mode 100644 index 000000000..84f8a6029 --- /dev/null +++ b/src/test/java/io/codeka/gaia/modules/api/DockerRegistryApiTest.kt @@ -0,0 +1,159 @@ +package io.codeka.gaia.modules.api + +import io.codeka.gaia.registries.controller.whenever +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.ArgumentMatchers.* +import org.mockito.Mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.client.RestTemplate + +@ExtendWith(MockitoExtension::class) +class DockerRegistryApiTest { + + lateinit var api: DockerRegistryApi + + @Mock + lateinit var restTemplate: RestTemplate + + @BeforeEach + fun setup() { + api = DockerRegistryApi("test_url", restTemplate) + } + + @Test + fun `findRepositoriesByName() should return a list of repositories matching a name`() { + // given + val repositories = listOf( + DockerRegistryRepository("solo/spices", "drugs"), + DockerRegistryRepository("solo/a280cfe", "blaster rifles")) + val response = ResponseEntity.ok(DockerRegistryResponse(repositories)) + + // when + whenever(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any>(), + eq(typeRef>()))) + .thenReturn(response) + val result = api.findRepositoriesByName("solo") + + // then + assertThat(result).isNotNull.isNotEmpty.hasSize(2) + verify(restTemplate, times(1)).exchange( + eq("test_url/search/repositories?query=solo&page=1&page_size=10"), + eq(HttpMethod.GET), + any>(), + eq(typeRef>())) + } + + @Test + fun `findRepositoriesByName() should return an empty list when no match`() { + // given + val response = ResponseEntity.ok>(null) + + // when + whenever(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any>(), + eq(typeRef>()))) + .thenReturn(response) + val result = api.findRepositoriesByName("solo") + + // then + assertThat(result).isNotNull.isEmpty() + } + + @Test + fun `findRepositoriesByName() should return an empty list when response is not ok`() { + // given + val repositories = emptyList() + val response = ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(DockerRegistryResponse(repositories)) + + // when + whenever(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any>(), + eq(typeRef>()))) + .thenReturn(response) + val result = api.findRepositoriesByName("solo") + + // then + assertThat(result).isNotNull.isEmpty() + } + + @Test + fun `findTagsByName() should return a list of tags for a repository`() { + // given + val tags = listOf( + DockerRegistryRepositoryTag("sw-4"), + DockerRegistryRepositoryTag("sw-5"), + DockerRegistryRepositoryTag("sw-6")) + val response = ResponseEntity.ok(DockerRegistryResponse(tags)) + + // when + whenever(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any>(), + eq(typeRef>()))) + .thenReturn(response) + val result = api.findTagsByName("sw-5", "lucas/original-movies") + + // then + assertThat(result).isNotNull.isNotEmpty.hasSize(3) + verify(restTemplate, times(1)).exchange( + eq("test_url/repositories/lucas/original-movies/tags?name=sw-5&page=1&page_size=10"), + eq(HttpMethod.GET), + any>(), + eq(typeRef>())) + } + + @Test + fun `findTagsByName() should return an empty list when no match`() { + // given + val response = ResponseEntity.ok>(null) + + // when + whenever(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any>(), + eq(typeRef>()))) + .thenReturn(response) + val result = api.findTagsByName("sw-10", "lucas/original-movies") + + // then + assertThat(result).isNotNull.isEmpty() + } + + @Test + fun `findTagsByName() should return an empty list when response is not ok`() { + // given + val tags = emptyList() + val response = ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(DockerRegistryResponse(tags)) + + // when + whenever(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any>(), + eq(typeRef>()))) + .thenReturn(response) + val result = api.findTagsByName("sw-1", "lucas/original-movies") + + // then + assertThat(result).isNotNull.isEmpty() + } + +} \ No newline at end of file diff --git a/src/test/java/io/codeka/gaia/modules/controller/DockerRegistryRestControllerIT.kt b/src/test/java/io/codeka/gaia/modules/controller/DockerRegistryRestControllerIT.kt new file mode 100644 index 000000000..9b355dbc5 --- /dev/null +++ b/src/test/java/io/codeka/gaia/modules/controller/DockerRegistryRestControllerIT.kt @@ -0,0 +1,106 @@ +package io.codeka.gaia.modules.controller; + +import io.codeka.gaia.test.MongoContainer +import org.hamcrest.Matchers.* +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.core.io.ClassPathResource +import org.springframework.http.MediaType +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.web.client.MockRestServiceServer.bindTo +import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.client.RestTemplate +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers + +@SpringBootTest +@DirtiesContext +@Testcontainers +@AutoConfigureWebClient +@AutoConfigureMockMvc +class DockerRegistryRestControllerIT { + + @Autowired + lateinit var mockMvc: MockMvc + + @Autowired + lateinit var restTemplate: RestTemplate + + companion object { + @Container + val mongoContainer = MongoContainer().withScript("src/test/resources/db/10_user.js") + } + + @Test + fun `resource repositories should return the repositories matching the query param name`() { + // given + val server = bindTo(restTemplate).build() + val urlToCall = "https://registry.hub.docker.com/v2/search/repositories?query=terraform&page=1&page_size=10" + server.expect(requestTo(urlToCall)).andRespond(withSuccess( + ClassPathResource("/rest/docker-hub/terraform-repositories.json"), MediaType.APPLICATION_JSON)) + + // when + mockMvc.perform(get("/api/docker/repositories") + .queryParam("name", "terraform") + .with(user("Mary J"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$", hasSize(3))) + .andExpect(jsonPath("$[0]name", equalTo("hashicorp/terraform"))) + .andExpect(jsonPath("$[0]description", equalTo("official one"))) + .andExpect(jsonPath("$[1]name", equalTo("rogue/terraform"))) + .andExpect(jsonPath("$[1]description", equalTo("rebels one"))) + .andExpect(jsonPath("$[2]name", equalTo("empire/terraform"))) + .andExpect(jsonPath("$[2]description", equalTo("empire one"))) + + // then + server.verify() + } + + @Test + fun `resource tags should return the tags for the repository`() { + // given + val server = bindTo(restTemplate).build() + val urlToCall = "https://registry.hub.docker.com/v2/repositories/hashicorp/terraform/tags?name=latest&page=1&page_size=10" + server.expect(requestTo(urlToCall)).andRespond(withSuccess( + ClassPathResource("/rest/docker-hub/terraform-tags.json"), MediaType.APPLICATION_JSON)) + + // when + mockMvc.perform(get("/api/docker/repositories/hashicorp/terraform/tags?name=latest") + .with(user("Mary J"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$", hasSize(3))) + .andExpect(jsonPath("$[0]name", equalTo("latest"))) + .andExpect(jsonPath("$[1]name", equalTo("light"))) + .andExpect(jsonPath("$[2]name", equalTo("full"))) + + // then + server.verify() + } + + @Test + fun `resource tags should return the tags for the repository when default owner`() { + // given + val server = bindTo(restTemplate).build() + val urlToCall = "https://registry.hub.docker.com/v2/repositories/library/terraform/tags?name=unknown&page=1&page_size=10" + server.expect(requestTo(urlToCall)).andRespond(withSuccess()) + + // when + mockMvc.perform(get("/api/docker/repositories/terraform/tags?name=unknown") + .with(user("Mary J"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$", empty())) + + // then + server.verify() + } + +} \ No newline at end of file diff --git a/src/test/java/io/codeka/gaia/modules/controller/DockerRegistryRestControllerTest.kt b/src/test/java/io/codeka/gaia/modules/controller/DockerRegistryRestControllerTest.kt new file mode 100644 index 000000000..9594258c6 --- /dev/null +++ b/src/test/java/io/codeka/gaia/modules/controller/DockerRegistryRestControllerTest.kt @@ -0,0 +1,52 @@ +package io.codeka.gaia.modules.controller; + +import io.codeka.gaia.modules.api.DockerRegistryApi +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.junit.jupiter.MockitoExtension + +@ExtendWith(MockitoExtension::class) +class DockerRegistryRestControllerTest { + + @Mock + lateinit var api: DockerRegistryApi + + lateinit var controller: DockerRegistryRestController + + @BeforeEach + fun setup() { + controller = DockerRegistryRestController(api) + } + + @Test + fun `listRepositoriesByName() should call the api`() { + // when + controller.listRepositoriesByName("han") + + // then + verify(api, times(1)).findRepositoriesByName("han") + } + + @Test + fun `listTagsByName() should call the api with name and owner`() { + // when + controller.listTagsByName("han", "solo", "spices") + + // then + verify(api).findTagsByName("spices", "solo/han") + } + + @Test + fun `listTagsByName() should call the api with name and default owner`() { + // when + controller.listTagsByName("chewie", null, "blasters") + + // then + verify(api).findTagsByName("blasters", "library/chewie") + } + +} \ No newline at end of file diff --git a/src/test/resources/rest/docker-hub/terraform-repositories.json b/src/test/resources/rest/docker-hub/terraform-repositories.json new file mode 100644 index 000000000..baba6df7e --- /dev/null +++ b/src/test/resources/rest/docker-hub/terraform-repositories.json @@ -0,0 +1,34 @@ +{ + "count": 2476, + "next": "https://registry.hub.docker.com/v2/search/repositories?page=2&page_size=10&query=terraform", + "previous": "", + "results": [ + { + "repo_name": "hashicorp/terraform", + "short_description": "official one", + "star_count": 220, + "pull_count": 206954884, + "repo_owner": "", + "is_automated": true, + "is_official": false + }, + { + "repo_name": "rogue/terraform", + "short_description": "rebels one", + "star_count": 1, + "pull_count": 794935057, + "repo_owner": "", + "is_automated": false, + "is_official": false + }, + { + "repo_name": "empire/terraform", + "short_description": "empire one", + "star_count": 0, + "pull_count": 21755910, + "repo_owner": "", + "is_automated": false, + "is_official": false + } + ] +} \ No newline at end of file diff --git a/src/test/resources/rest/docker-hub/terraform-tags.json b/src/test/resources/rest/docker-hub/terraform-tags.json new file mode 100644 index 000000000..b533b90c8 --- /dev/null +++ b/src/test/resources/rest/docker-hub/terraform-tags.json @@ -0,0 +1,79 @@ +{ + "count": 90, + "next": "https://registry.hub.docker.com/v2/repositories/hashicorp/terraform/tags?page=2&page_size=10", + "previous": null, + "results": [ + { + "name": "latest", + "full_size": 30241765, + "images": [ + { + "size": 30241765, + "digest": "sha256:5e19b9bab0b6d079cae8822be22cd7010f65177356600154b77fc4fc81bdde31", + "architecture": "amd64", + "os": "linux", + "os_version": null, + "os_features": "", + "variant": null, + "features": "" + } + ], + "id": 2733924, + "repository": 646700, + "creator": 1522, + "last_updater": 1022548, + "last_updater_username": "hashicorpterraform", + "image_id": null, + "v2": true, + "last_updated": "2020-01-22T22:28:07.008561Z" + }, + { + "name": "light", + "full_size": 30241765, + "images": [ + { + "size": 30241765, + "digest": "sha256:5e19b9bab0b6d079cae8822be22cd7010f65177356600154b77fc4fc81bdde31", + "architecture": "amd64", + "os": "linux", + "os_version": null, + "os_features": "", + "variant": null, + "features": "" + } + ], + "id": 2733971, + "repository": 646700, + "creator": 1522, + "last_updater": 1022548, + "last_updater_username": "hashicorpterraform", + "image_id": null, + "v2": true, + "last_updated": "2020-01-22T22:28:05.128751Z" + }, + { + "name": "full", + "full_size": 678199689, + "images": [ + { + "size": 678199689, + "digest": "sha256:bb99fa8c34f8700b765f99aa5308f24f13b5d413aa039269e80cb8ae5939293e", + "architecture": "amd64", + "os": "linux", + "os_version": null, + "os_features": "", + "variant": null, + "features": "" + } + ], + "id": 2747995, + "repository": 646700, + "creator": 15379, + "last_updater": 1022548, + "last_updater_username": "hashicorpterraform", + "image_id": null, + "v2": true, + "last_updated": "2020-01-22T22:28:03.228375Z" + } + ] +} \ No newline at end of file