[ADD] Docker 이미지 Pull API 추가: DockerPullPushService 구현 및 컨트롤러에 pullImage 메서드 추가

main
bjkim 8 months ago
parent d648e70eba
commit 81b351360f

@ -2,7 +2,9 @@ package kr.re.etri.autoflow.controllers;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import io.swagger.v3.oas.annotations.Operation; 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.payload.request.TagWithDigest;
import kr.re.etri.autoflow.service.DockerPullPushService;
import kr.re.etri.autoflow.service.DockerRegistryService; import kr.re.etri.autoflow.service.DockerRegistryService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@ -19,6 +21,7 @@ import java.util.Map;
public class DockerRegistryController { public class DockerRegistryController {
private final DockerRegistryService dockerRegistryService; private final DockerRegistryService dockerRegistryService;
private final DockerPullPushService dockerPullPushService;
@GetMapping("/repositories") @GetMapping("/repositories")
@Operation(summary = "모든 리포지토리 조회") @Operation(summary = "모든 리포지토리 조회")
@ -43,4 +46,14 @@ public class DockerRegistryController {
return dockerRegistryService.listTags(repo) return dockerRegistryService.listTags(repo)
.map(ResponseEntity::ok); .map(ResponseEntity::ok);
} }
@Operation(summary = "이미지 pull (manifest + config + blobs) 후 로컬에 저장")
@PostMapping("/pull")
public Mono<ResponseEntity<Map<String, Object>>> 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()
)));
}
} }

@ -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;
}

@ -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<List<String>> 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<String> saved = new ArrayList<>();
saved.add(manifestPath.toString());
// 2) config (config is a blob)
JsonNode config = manifest.get("config");
Mono<Void> 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<Void> layersFlux = Flux.empty();
if (layers != null && layers.isArray()) {
List<Mono<Void>> monos = new ArrayList<>();
for (JsonNode layer : layers) {
String digest = layer.get("digest").asText();
Path layerPath = dstDir.resolve(digest);
Mono<Void> 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<Path> 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);
}
}
}
Loading…
Cancel
Save