Procházet zdrojové kódy

feat: add Chapter 10 - image push to local registry (docker login/tag/push round trip)

yangyi před 2 dny
rodič
revize
6801e12a8a

+ 216 - 0
src/main/java/space/anyi/docker/RegistryPushAPI.java

@@ -0,0 +1,216 @@
+package space.anyi.docker;
+
+import com.github.dockerjava.api.DockerClient;
+import com.github.dockerjava.api.async.ResultCallback;
+import com.github.dockerjava.api.command.CreateContainerResponse;
+import com.github.dockerjava.api.command.PullImageResultCallback;
+import com.github.dockerjava.api.model.AuthConfig;
+import com.github.dockerjava.api.model.AuthResponse;
+import com.github.dockerjava.api.model.ExposedPort;
+import com.github.dockerjava.api.model.HostConfig;
+import com.github.dockerjava.api.model.Ports;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+
+/**
+ * Docker 镜像推送与 Registry 认证 API 示例
+ * 以本地 registry 演示 docker login / docker tag / docker push / docker pull 的完整闭环
+ * 每个操作旁标注了等价的 Docker CLI 命令,方便对照理解
+ */
+public class RegistryPushAPI {
+    private static final Logger log = LoggerFactory.getLogger(RegistryPushAPI.class);
+
+    private final DockerClient dockerClient;
+
+    /** 本地私有仓库镜像 */
+    public static final String REGISTRY_IMAGE = "registry:2";
+
+    public RegistryPushAPI() {
+        this.dockerClient = DockerClientFactory.createDockerClient();
+    }
+
+    /**
+     * 启动一个本地私有仓库容器(registry:2)
+     * 等价命令:
+     *   docker run -d --name my-registry -p 5000:5000 registry:2
+     * @param containerName 容器名称
+     * @param hostPort 宿主机端口(映射到仓库的 5000 端口)
+     * @return 容器 ID
+     */
+    public String startLocalRegistry(String containerName, int hostPort) {
+        // 等价命令: docker run -d --name my-registry -p 5000:5000 registry:2
+        ExposedPort registryPort = ExposedPort.tcp(5000);
+        Ports portBindings = new Ports();
+        portBindings.bind(registryPort, Ports.Binding.bindPort(hostPort));
+
+        HostConfig hostConfig = HostConfig.newHostConfig().withPortBindings(portBindings);
+
+        CreateContainerResponse container = dockerClient.createContainerCmd(REGISTRY_IMAGE)
+                .withName(containerName)
+                .withExposedPorts(registryPort)
+                .withHostConfig(hostConfig)
+                .exec();
+        dockerClient.startContainerCmd(container.getId()).exec();
+        log.info("本地私有仓库已启动: http://localhost:{}/", hostPort);
+        return container.getId();
+    }
+
+    /**
+     * 向私有仓库进行认证(等价 docker login)
+     * 本地 registry 默认未开启认证,注册任意凭据都会返回 Login Succeeded
+     * 等价命令: docker login localhost:5000
+     * @param registryAddress 仓库地址,如 "localhost:5000"
+     * @return 认证结果状态
+     */
+    public String authToRegistry(String registryAddress) {
+        // 等价命令: docker login localhost:5000
+        // 真实场景下(如 Docker Hub)需要提供有效的用户名/密码,
+        // 本地私有仓库默认不校验,任意凭据即可
+        AuthConfig authConfig = new AuthConfig()
+                .withUsername("docker-java-demo")
+                .withPassword("demo-password")
+                .withRegistryAddress(registryAddress);
+
+        AuthResponse response = dockerClient.authCmd()
+                .withAuthConfig(authConfig)
+                .exec();
+        log.info("Registry {} 认证结果: {}", registryAddress, response.getStatus());
+        return response.getStatus();
+    }
+
+    /**
+     * 将本地镜像打标签后推送到私有仓库
+     * 等价命令:
+     *   docker tag nginx:latest localhost:5000/my-nginx:v1
+     *   docker push localhost:5000/my-nginx:v1
+     * @param sourceImage 源镜像(本地已有),如 "nginx:latest"
+     * @param registryAddress 仓库地址,如 "localhost:5000"
+     * @param repository 仓库名,如 "my-nginx"
+     * @param tag 标签,如 "v1"
+     * @return 推送是否成功
+     */
+    public boolean tagAndPushImage(String sourceImage, String registryAddress,
+                                   String repository, String tag) {
+        String remoteImage = registryAddress + "/" + repository;
+
+        // 等价命令: docker tag nginx:latest localhost:5000/my-nginx:v1
+        dockerClient.tagImageCmd(sourceImage, remoteImage, tag).exec();
+
+        try {
+            // 等价命令: docker push localhost:5000/my-nginx:v1
+            // 3.7.1 没有 PushImageResultCallback,用通用回调 Adapter 接收推送进度
+            // withTag 指定要推送的标签;awaitCompletion() 等待推送完成
+            dockerClient.pushImageCmd(remoteImage)
+                    .withTag(tag)
+                    .exec(new ResultCallback.Adapter<>())
+                    .awaitCompletion();
+            log.info("镜像 {}:{} 推送成功", remoteImage, tag);
+            return true;
+        } catch (InterruptedException e) {
+            log.error("镜像推送被中断: {}", e.getMessage());
+            Thread.currentThread().interrupt();
+            return false;
+        }
+    }
+
+    /**
+     * 从私有仓库拉取镜像(验证推送结果的逆向操作)
+     * 等价命令: docker pull localhost:5000/my-nginx:v1
+     * @param registryAddress 仓库地址
+     * @param repository 仓库名
+     * @param tag 标签
+     * @return 拉取是否成功
+     */
+    public boolean pullFromRegistry(String registryAddress, String repository, String tag) {
+        String image = registryAddress + "/" + repository + ":" + tag;
+        try {
+            // 等价命令: docker pull localhost:5000/my-nginx:v1
+            dockerClient.pullImageCmd(image)
+                    .exec(new PullImageResultCallback())
+                    .awaitCompletion();
+            log.info("镜像 {} 从仓库拉取成功", image);
+            return true;
+        } catch (InterruptedException e) {
+            log.error("镜像拉取被中断: {}", e.getMessage());
+            Thread.currentThread().interrupt();
+            return false;
+        }
+    }
+
+    /**
+     * 删除本地镜像(测试清理用)
+     * 等价命令: docker rmi <image>
+     * @param image 镜像名称
+     */
+    public void removeImage(String image) {
+        dockerClient.removeImageCmd(image).withForce(true).exec();
+        log.info("镜像 {} 已删除", image);
+    }
+
+    /**
+     * 检查本地是否存在指定镜像
+     * @param image 镜像名称(仓库:标签)
+     * @return 是否存在
+     */
+    public boolean checkImageExists(String image) {
+        // 等价命令: docker images | grep <image>
+        return dockerClient.listImagesCmd().exec().stream()
+                .anyMatch(img -> img.getRepoTags() != null &&
+                        Arrays.asList(img.getRepoTags()).contains(image));
+    }
+
+    /**
+     * 停止并删除 registry 容器(测试清理用)
+     * 等价命令: docker rm -f <containerId>
+     * @param containerId 容器 ID
+     */
+    public void stopAndRemoveRegistryContainer(String containerId) {
+        dockerClient.removeContainerCmd(containerId)
+                .withForce(true)
+                .withRemoveVolumes(false)
+                .exec();
+        log.info("本地私有仓库容器 {} 已停止并删除", containerId);
+    }
+
+    /**
+     * 完整演示:启动仓库 -> 认证 -> 打标签 -> 推送 -> 删除本地 -> 重新拉取
+     * @param containerName registry 容器名
+     * @param hostPort registry 宿主机端口
+     * @return 完整流程是否成功
+     */
+    public boolean fullRegistryRoundTrip(String containerName, int hostPort, String repository, String tag) {
+        String registryAddress = "localhost:" + hostPort;
+        String remoteImage = registryAddress + "/" + repository + ":" + tag;
+        try {
+            // 1. 启动本地私有仓库
+            String registryContainerId = startLocalRegistry(containerName, hostPort);
+
+            // 2. docker login
+            String status = authToRegistry(registryAddress);
+            log.info("认证状态: {}", status);
+
+            // 3. docker tag + docker push
+            boolean pushed = tagAndPushImage("nginx:latest", registryAddress, repository, tag);
+            if (!pushed) return false;
+
+            // 4. 删除本地副本,模拟"仓库是唯一来源"
+            if (checkImageExists(remoteImage)) {
+                removeImage(remoteImage);
+            }
+
+            // 5. 从仓库重新拉取,验证闭环
+            boolean pulled = pullFromRegistry(registryAddress, repository, tag);
+            log.info("完整推送-拉取闭环结果: {}", pulled);
+
+            // 清理
+            removeImage(remoteImage);
+            stopAndRemoveRegistryContainer(registryContainerId);
+            return pulled;
+        } catch (Exception e) {
+            log.error("Registry 完整流程失败: {}", e.getMessage());
+            return false;
+        }
+    }
+}

+ 118 - 0
src/test/java/space/anyi/docker/RegistryPushAPITest.java

@@ -0,0 +1,118 @@
+package space.anyi.docker;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 镜像推送与 Registry 认证 API 的测试类
+ * 注意:需要本机 Docker daemon 运行中(会自动拉取 registry:2 镜像)
+ * 测试会启动一个本地私有仓库,结束后自动清理
+ */
+class RegistryPushAPITest {
+
+    private String uniqueName(String prefix) {
+        return prefix + "-" + UUID.randomUUID().toString().substring(0, 8);
+    }
+
+    /**
+     * 动态获取一个空闲端口,避免与其他进程冲突
+     */
+    private int freePort() throws IOException {
+        try (ServerSocket socket = new ServerSocket(0)) {
+            return socket.getLocalPort();
+        }
+    }
+
+    /**
+     * 测试 docker login:对本地私有仓库认证应返回 Login Succeeded
+     */
+    @Test
+    void authToRegistryReturnsLoginSucceeded() throws IOException {
+        RegistryPushAPI api = new RegistryPushAPI();
+        int port = freePort();
+        String containerId = api.startLocalRegistry(uniqueName("reg"), port);
+
+        String registryAddress = "localhost:" + port;
+        String status = api.authToRegistry(registryAddress);
+        assertEquals("Login Succeeded", status, "未启用认证的私有仓库应返回 Login Succeeded");
+
+        api.stopAndRemoveRegistryContainer(containerId);
+    }
+
+    /**
+     * 测试完整闭环:启动仓库 -> 打标签 -> 推送 -> 验证仓库有镜像 -> 删本地 -> 拉回
+     */
+    @Test
+    void pushThenPullRoundTrip() throws IOException, InterruptedException {
+        RegistryPushAPI api = new RegistryPushAPI();
+        int port = freePort();
+        String registryContainerId = api.startLocalRegistry(uniqueName("reg"), port);
+        String repository = uniqueName("myapp");
+        String tag = "v1";
+        String registryAddress = "localhost:" + port;
+        String remoteImage = registryAddress + "/" + repository + ":" + tag;
+
+        try {
+            // 1. 认证
+            assertEquals("Login Succeeded", api.authToRegistry(registryAddress));
+
+            // 2. 打标签并推送
+            boolean pushed = api.tagAndPushImage("nginx:latest", registryAddress, repository, tag);
+            assertTrue(pushed, "镜像应推送成功");
+
+            // 3. 通过 Registry HTTP API 验证仓库中确实存在该镜像
+            //    等价命令: curl http://localhost:5000/v2/_catalog
+            HttpResponse<String> catalogResponse = queryRegistry(port);
+            String catalogBody = catalogResponse.body();
+            assertTrue(catalogBody.contains(repository),
+                    "仓库目录中应包含推送的镜像,实际: " + catalogBody);
+
+            // 4. 删除本地镜像副本(模拟镜像仅存在于仓库)
+            api.removeImage(remoteImage);
+            assertFalse(api.checkImageExists(remoteImage), "本地镜像应已被删除");
+
+            // 5. 从仓库拉回并验证
+            boolean pulled = api.pullFromRegistry(registryAddress, repository, tag);
+            assertTrue(pulled, "镜像应能从仓库拉回");
+            assertTrue(api.checkImageExists(remoteImage), "拉回后本地应存在该镜像");
+        } finally {
+            api.removeImage(remoteImage);
+            api.stopAndRemoveRegistryContainer(registryContainerId);
+        }
+    }
+
+    /**
+     * 查询本地仓库目录(Registry HTTP API v2)
+     * 等价命令: curl http://localhost:<port>/v2/_catalog
+     */
+    private HttpResponse<String> queryRegistry(int port) throws IOException, InterruptedException {
+        HttpClient client = HttpClient.newBuilder()
+                .connectTimeout(Duration.ofSeconds(3))
+                .build();
+        HttpRequest request = HttpRequest.newBuilder(
+                        URI.create("http://localhost:" + port + "/v2/_catalog"))
+                .GET()
+                .build();
+        HttpResponse<String> response = null;
+        for (int i = 0; i < 10; i++) {
+            try {
+                response = client.send(request, HttpResponse.BodyHandlers.ofString());
+                break;
+            } catch (IOException | InterruptedException e) {
+                Thread.sleep(1000);
+            }
+        }
+        assertNotNull(response, "本地仓库未能及时响应");
+        return response;
+    }
+}