diff --git a/src/main/java/kr/re/etri/autoflow/controllers/DockerRegistryController.java b/src/main/java/kr/re/etri/autoflow/controllers/DockerRegistryController.java index fdd25e7..afcc451 100644 --- a/src/main/java/kr/re/etri/autoflow/controllers/DockerRegistryController.java +++ b/src/main/java/kr/re/etri/autoflow/controllers/DockerRegistryController.java @@ -2,7 +2,9 @@ package kr.re.etri.autoflow.controllers; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.Operation; +import kr.re.etri.autoflow.entity.PullRequest; import kr.re.etri.autoflow.payload.request.TagWithDigest; +import kr.re.etri.autoflow.service.DockerPullPushService; import kr.re.etri.autoflow.service.DockerRegistryService; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; @@ -19,6 +21,7 @@ import java.util.Map; public class DockerRegistryController { private final DockerRegistryService dockerRegistryService; + private final DockerPullPushService dockerPullPushService; @GetMapping("/repositories") @Operation(summary = "모든 리포지토리 조회") @@ -43,4 +46,14 @@ public class DockerRegistryController { return dockerRegistryService.listTags(repo) .map(ResponseEntity::ok); } + + @Operation(summary = "이미지 pull (manifest + config + blobs) 후 로컬에 저장") + @PostMapping("/pull") + public Mono>> pullImage(@RequestBody PullRequest req) { + return dockerPullPushService.pullImage(req.getRegistry(), req.getRepository(), req.getTag(), req.getUsername(), req.getPassword()) + .map(result -> ResponseEntity.ok(Map.of( + "savedFiles", result, + "path", dockerPullPushService.getDownloadBaseDir() + ))); + } } diff --git a/src/main/java/kr/re/etri/autoflow/entity/PullRequest.java b/src/main/java/kr/re/etri/autoflow/entity/PullRequest.java new file mode 100644 index 0000000..374e1fd --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/entity/PullRequest.java @@ -0,0 +1,12 @@ +package kr.re.etri.autoflow.entity; + +import lombok.Data; + +@Data +public class PullRequest { + private String registry; + private String repository; + private String tag; + private String username; + private String password; +} \ No newline at end of file diff --git a/src/main/java/kr/re/etri/autoflow/service/DockerPullPushService.java b/src/main/java/kr/re/etri/autoflow/service/DockerPullPushService.java new file mode 100644 index 0000000..b73e920 --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/service/DockerPullPushService.java @@ -0,0 +1,133 @@ +package kr.re.etri.autoflow.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Service; +import org.springframework.util.FileSystemUtils; +import org.springframework.web.reactive.function.BodyExtractors; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class DockerPullPushService { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${docker-registry.download-dir:./downloads}") + private String downloadBaseDir; + + public String getDownloadBaseDir() { + return downloadBaseDir; + } + + public Mono> pullImage(String registry, String repository, String tag, String username, String password) { + if (registry == null || registry.isBlank()) { + registry = "localhost:5000"; // 기본값 + } + + String base = registry.startsWith("http") ? registry : "http://" + registry; + WebClient.Builder builder = WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(HttpClient.create().responseTimeout(Duration.ofSeconds(60)))) + .baseUrl(base); + + if (username != null && !username.isBlank()) { + builder.defaultHeaders(h -> h.setBasicAuth(username, password == null ? "" : password)); + } + + WebClient client = builder.build(); + + Path dstDir = Path.of(downloadBaseDir, repository.replace('/', '-'), tag); + try { + if (Files.exists(dstDir)) { + FileSystemUtils.deleteRecursively(dstDir); + } + Files.createDirectories(dstDir); + } catch (IOException e) { + return Mono.error(e); + } + + // 1) manifest + String manifestAccept = "application/vnd.docker.distribution.manifest.v2+json"; + return client.get() + .uri(uriBuilder -> uriBuilder.path("/v2/{repo}/manifests/{tag}").build(repository, tag)) + .header(HttpHeaders.ACCEPT, manifestAccept) + .accept(MediaType.valueOf("application/vnd.docker.distribution.manifest.v2+json")) + .exchangeToMono(response -> saveResponseBodyToFile(response, dstDir.resolve("image.manifest"))) + .flatMap(manifestPath -> { + try { + byte[] b = Files.readAllBytes(manifestPath); + JsonNode manifest = objectMapper.readTree(b); + List saved = new ArrayList<>(); + saved.add(manifestPath.toString()); + + // 2) config (config is a blob) + JsonNode config = manifest.get("config"); + Mono configMono = Mono.empty(); + if (config != null && config.has("digest")) { + String configDigest = config.get("digest").asText(); + Path configPath = dstDir.resolve(configDigest); + configMono = client.get() + .uri(uriBuilder -> uriBuilder.path("/v2/{repo}/blobs/{digest}").build(repository, configDigest)) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_OCTET_STREAM_VALUE) + .exchangeToMono(response -> saveResponseBodyToFile(response, configPath).then()) + .doOnSuccess(v -> saved.add(configPath.toString())); + } + + // 3) layers + JsonNode layers = manifest.get("layers"); + Flux layersFlux = Flux.empty(); + if (layers != null && layers.isArray()) { + List> monos = new ArrayList<>(); + for (JsonNode layer : layers) { + String digest = layer.get("digest").asText(); + Path layerPath = dstDir.resolve(digest); + Mono m = client.get() + .uri(uriBuilder -> uriBuilder.path("/v2/{repo}/blobs/{digest}").build(repository, digest)) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_OCTET_STREAM_VALUE) + .exchangeToMono(response -> saveResponseBodyToFile(response, layerPath).then()) + .doOnSuccess(v -> saved.add(layerPath.toString())); + monos.add(m); + } + layersFlux = Flux.concat(monos); + } + + return configMono.thenMany(layersFlux).then(Mono.just(saved)); + } catch (IOException e) { + return Mono.error(e); + } + }); + } + + private Mono saveResponseBodyToFile(ClientResponse response, Path path) { + if (response.statusCode().is2xxSuccessful()) { + return response.body(BodyExtractors.toDataBuffers()) + .map(dataBuffer -> { + try (var in = dataBuffer.asInputStream(true)) { + Files.copy(in, path); + } catch (IOException e) { + throw new RuntimeException(e); + } + return path; + }) + .next() + .onErrorMap(t -> t); + } else { + return response.createException().flatMap(Mono::error); + } + } +}