From 0c22c832e0184711a0ccfaeab86de153dcba6b56 Mon Sep 17 00:00:00 2001 From: bjkim Date: Mon, 29 Sep 2025 18:41:01 +0900 Subject: [PATCH] =?UTF-8?q?[ADD]=20Docker=20Registry=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EB=B0=8F=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C/=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=93=B1=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/DockerRegistryController.java | 93 +++++++++++++ .../payload/request/TagWithDigest.java | 11 ++ .../service/DockerRegistryService.java | 131 ++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 src/main/java/kr/re/etri/autoflow/controllers/DockerRegistryController.java create mode 100644 src/main/java/kr/re/etri/autoflow/payload/request/TagWithDigest.java create mode 100644 src/main/java/kr/re/etri/autoflow/service/DockerRegistryService.java diff --git a/src/main/java/kr/re/etri/autoflow/controllers/DockerRegistryController.java b/src/main/java/kr/re/etri/autoflow/controllers/DockerRegistryController.java new file mode 100644 index 0000000..6566a6f --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/controllers/DockerRegistryController.java @@ -0,0 +1,93 @@ +package kr.re.etri.autoflow.controllers; + +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.Operation; +import kr.re.etri.autoflow.payload.request.TagWithDigest; +import kr.re.etri.autoflow.service.DockerRegistryService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/docker") +@RequiredArgsConstructor +public class DockerRegistryController { + + private final DockerRegistryService dockerRegistryService; + + @GetMapping("/repositories") + @Operation(summary = "모든 리포지토리 조회") + public Mono> listRepositories() { + return dockerRegistryService.listRepositories() + .map(ResponseEntity::ok); + } + + @GetMapping("/repositories/search") + @Operation(summary = "리포지토리 검색 (키워드 + 페이지네이션)") + public Mono> searchRepositories( + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return dockerRegistryService.searchRepositories(keyword, page, size) + .map(ResponseEntity::ok); + } + + @GetMapping("/repositories/{repo}/tags") + @Operation(summary = "특정 리포지토리의 태그 조회") + public Mono> listTags(@PathVariable String repo) { + return dockerRegistryService.listTags(repo) + .map(ResponseEntity::ok); + } + + @GetMapping("/repositories/{repo}/tags-with-digest") + @Operation(summary = "태그 목록 + digest 조회") + public Mono>> listTagsWithDigest( + @PathVariable String repo) { + + return dockerRegistryService.listTagsWithDigest(repo) + .map(ResponseEntity::ok); + } + + @PostMapping(value = "/repositories/{repo}/upload", consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE) + @Operation(summary = "이미지 업로드") + public Mono> uploadImage(@PathVariable String repo, + @RequestParam String tag, + @RequestBody byte[] content) { + return dockerRegistryService.uploadImage(repo, tag, content) + .map(ResponseEntity::ok); + } + + @GetMapping("/repositories/{repo}/download/{digest}") + @Operation(summary = "이미지 다운로드") + public Mono> downloadImage(@PathVariable String repo, + @PathVariable String digest) { + return dockerRegistryService.downloadImage(repo, digest) + .map(bytes -> ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(bytes)); + } + + @GetMapping("/repositories/{repo}/manifest/{tag}") + @Operation(summary = "태그로 manifest 조회") + public Mono> getManifest( + @PathVariable String repo, + @PathVariable String tag) { + return dockerRegistryService.getManifestByTag(repo, tag) + .map(ResponseEntity::ok); + } + + @GetMapping("/repositories/{repo}/digest/{tag}") + @Operation(summary = "태그로 digest 조회") + public Mono> getDigest( + @PathVariable String repo, + @PathVariable String tag) { + return dockerRegistryService.getDigestByTag(repo, tag) + .map(ResponseEntity::ok); + } + +} diff --git a/src/main/java/kr/re/etri/autoflow/payload/request/TagWithDigest.java b/src/main/java/kr/re/etri/autoflow/payload/request/TagWithDigest.java new file mode 100644 index 0000000..d559898 --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/payload/request/TagWithDigest.java @@ -0,0 +1,11 @@ +package kr.re.etri.autoflow.payload.request; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TagWithDigest { + private String tag; + private String digest; +} diff --git a/src/main/java/kr/re/etri/autoflow/service/DockerRegistryService.java b/src/main/java/kr/re/etri/autoflow/service/DockerRegistryService.java new file mode 100644 index 0000000..d5b5095 --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/service/DockerRegistryService.java @@ -0,0 +1,131 @@ +package kr.re.etri.autoflow.service; + +import com.fasterxml.jackson.databind.JsonNode; +import kr.re.etri.autoflow.payload.request.TagWithDigest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +@Service +@RequiredArgsConstructor +public class DockerRegistryService { + + private final WebClient.Builder webClientBuilder; + private static final String REGISTRY_URL = "http://192.168.10.135:5000"; + + public Mono listRepositories() { + return webClientBuilder.build() + .get() + .uri(REGISTRY_URL + "/v2/_catalog") + .retrieve() + .bodyToMono(JsonNode.class); + } + + // 검색 + 페이지네이션 + public Mono> searchRepositories(String keyword, int page, int size) { + return webClientBuilder.build() + .get() + .uri(REGISTRY_URL + "/v2/_catalog") + .retrieve() + .bodyToMono(JsonNode.class) + .map(json -> { + JsonNode repositoriesNode = json.get("repositories"); + if (repositoriesNode == null || !repositoriesNode.isArray()) { + return List.of(); + } + + List allRepos = StreamSupport.stream(repositoriesNode.spliterator(), false) + .map(JsonNode::asText) + .collect(Collectors.toList()); + + // 키워드 필터링 + if (keyword != null && !keyword.isBlank()) { + allRepos = allRepos.stream() + .filter(name -> name.contains(keyword)) + .collect(Collectors.toList()); + } + + // 페이지네이션 + int fromIndex = Math.min(page * size, allRepos.size()); + int toIndex = Math.min(fromIndex + size, allRepos.size()); + return allRepos.subList(fromIndex, toIndex); + }); + } + + + public Mono listTags(String repository) { + return webClientBuilder.build() + .get() + .uri(REGISTRY_URL + "/v2/" + repository + "/tags/list") + .retrieve() + .bodyToMono(JsonNode.class); + } + + public Mono> listTagsWithDigest(String repository) { + return listTags(repository) + .flatMapMany(json -> { + JsonNode tagsNode = json.get("tags"); + if (tagsNode == null || !tagsNode.isArray()) { + return Flux.empty(); + } + return Flux.fromIterable( + StreamSupport.stream(tagsNode.spliterator(), false) + .map(JsonNode::asText) + .toList() + ); + }) + .flatMap(tag -> getDigestByTag(repository, tag) + .map(digest -> new TagWithDigest(tag, digest)) + ) + .collectList(); + } + + + public Mono uploadImage(String repository, String tag, byte[] content) { + return webClientBuilder.build() + .put() + .uri(REGISTRY_URL + "/v2/" + repository + "/blobs/uploads/") + .header(HttpHeaders.CONTENT_TYPE, "application/octet-stream") + .bodyValue(content) + .retrieve() + .bodyToMono(String.class); + } + + public Mono downloadImage(String repository, String digest) { + return webClientBuilder.build() + .get() + .uri(REGISTRY_URL + "/v2/" + repository + "/blobs/" + digest) + .retrieve() + .bodyToMono(byte[].class); + } + + public Mono getManifestByTag(String repository, String tag) { + return webClientBuilder.build() + .get() + .uri(REGISTRY_URL + "/v2/" + repository + "/manifests/" + tag) + .header("Accept", "application/vnd.oci.image.index.v1+json") + .retrieve() + .bodyToMono(JsonNode.class); + } + + // SHA(Digest)값만 가져옴 + public Mono getDigestByTag(String repository, String tag) { + return webClientBuilder.build() + .get() + .uri(REGISTRY_URL + "/v2/" + repository + "/manifests/" + tag) + .header("Accept", "application/vnd.oci.image.index.v1+json") + .retrieve() + .toBodilessEntity() + .map(resp -> resp.getHeaders().getFirst("Docker-Content-Digest")); + } +}