package com.xuqm.tenant.service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.io.BufferedReader; import java.io.InputStreamReader; import java.nio.file.Files; import java.nio.file.Paths; import java.util.List; import java.util.function.Consumer; @Service public class SystemUpdateService { private static final Logger log = LoggerFactory.getLogger(SystemUpdateService.class); private static final List OTHER_SERVICES = List.of( "file-service", "tenant-web", "im-service", "push-service", "update-service", "license-service" ); @Value("${PRIVATE_DEPLOY_ROOT:/opt/xuqm-private}") private String deployRoot; public void runUpdate(Consumer emit) { String composeFile = deployRoot + "/docker-compose.yml"; // Authenticate to the registry before pulling (credentials stored in .env by deploy.sh) dockerLogin(emit); emit.accept(">>> 拉取最新镜像..."); for (String svc : OTHER_SERVICES) { if (isRunning(svc)) { emit.accept(" pulling " + svc + " ..."); exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", svc); } } emit.accept(" pulling tenant-service ..."); exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", "tenant-service"); emit.accept(">>> 镜像拉取完成"); emit.accept(">>> 重启各服务..."); for (String svc : OTHER_SERVICES) { if (isRunning(svc)) { emit.accept(" restarting " + svc + " ..."); exec(emit, "docker", "compose", "-f", composeFile, "up", "-d", "--no-deps", "--force-recreate", svc); emit.accept(" " + svc + " ✓"); } } // tenant-service 自身的重建:不能直接用 docker compose,因为一旦发出 stop 指令, // 当前容器(含 docker compose 进程)会立即被杀死,后续的 rm/create/start 步骤不会执行。 // 解决方案:先用 docker run -d 启动一个独立助手容器,它不依附于 tenant-service, // 能在 tenant-service 停止后继续完成重建。 emit.accept(">>> 启动自更新助手容器..."); String selfImage = getCurrentImage(); if (selfImage == null) { emit.accept(">>> [错误] 无法获取当前 tenant-service 镜像名,请检查容器标签或手动执行更新。"); emit.accept("DONE"); return; } boolean helperStarted = spawnSelfUpdater(composeFile, selfImage); if (helperStarted) { emit.accept(">>> 助手容器已就绪,tenant-service 即将重建(连接将短暂中断)..."); emit.accept("RESTART_SELF"); } else { emit.accept(">>> [警告] 助手容器启动失败,请手动执行:"); emit.accept(">>> docker compose -f " + composeFile + " up -d --no-deps --force-recreate tenant-service"); emit.accept("DONE"); } } /** * Read REGISTRY / REGISTRY_USER / REGISTRY_PASSWORD from deployRoot/.env and * run "docker login" so that subsequent pulls succeed on private registries. */ private void dockerLogin(Consumer emit) { try { String registry = null, user = null, password = null; for (String line : Files.readAllLines(Paths.get(deployRoot + "/.env"))) { if (line.startsWith("REGISTRY=")) registry = line.substring("REGISTRY=".length()).trim(); else if (line.startsWith("REGISTRY_USER=")) user = line.substring("REGISTRY_USER=".length()).trim(); else if (line.startsWith("REGISTRY_PASSWORD=")) password = line.substring("REGISTRY_PASSWORD=".length()).trim(); } if (registry == null || user == null || password == null || password.isEmpty()) return; // REGISTRY format: host/namespace → extract host String host = registry.contains("/") ? registry.substring(0, registry.indexOf('/')) : registry; ProcessBuilder pb = new ProcessBuilder("docker", "login", host, "-u", user, "--password-stdin") .redirectErrorStream(true); Process p = pb.start(); p.getOutputStream().write((password + "\n").getBytes()); p.getOutputStream().flush(); p.getOutputStream().close(); String out = new String(p.getInputStream().readAllBytes()).trim(); int code = p.waitFor(); if (code == 0) { emit.accept(" 已完成镜像仓库登录"); } else { emit.accept(" [警告] 镜像仓库登录失败,将使用本地缓存(" + out + ")"); } } catch (Exception e) { emit.accept(" [警告] 读取仓库凭据失败: " + e.getMessage()); } } /** * 启动一个独立的 detached 容器,在 tenant-service 被停止后重建它。 * 助手容器与 tenant-service 无父子关系,不会随 tenant-service 终止。 */ private boolean spawnSelfUpdater(String composeFile, String image) { try { // 清理上次残留(若有) new ProcessBuilder("docker", "rm", "-f", "xuqm-self-updater") .redirectErrorStream(true).start().waitFor(); // 等待 8 秒确保 tenant-service 已完全停止,然后执行 force-recreate String shellCmd = "sleep 8 && docker compose -f " + composeFile + " up -d --no-deps --force-recreate tenant-service"; Process p = new ProcessBuilder( "docker", "run", "-d", "--rm", "--name", "xuqm-self-updater", "-v", "/var/run/docker.sock:/var/run/docker.sock", "-v", deployRoot + ":" + deployRoot, "--entrypoint", "sh", image, "-c", shellCmd ).redirectErrorStream(true).start(); String out = new String(p.getInputStream().readAllBytes()).trim(); int code = p.waitFor(); log.info("self-updater spawn: code={} containerId={}", code, out); return code == 0; } catch (Exception e) { log.error("failed to spawn self-updater", e); return false; } } // Filter only by service label — avoids dependency on project name which varies by deploy root dir. private boolean isRunning(String service) { try { Process p = new ProcessBuilder( "docker", "ps", "-q", "--filter", "label=com.docker.compose.service=" + service ).redirectErrorStream(true).start(); String out = new String(p.getInputStream().readAllBytes()).trim(); p.waitFor(); return !out.isEmpty(); } catch (Exception e) { return false; } } private String getCurrentImage() { try { Process p = new ProcessBuilder( "docker", "ps", "--filter", "label=com.docker.compose.service=tenant-service", "--format", "{{.Image}}" ).redirectErrorStream(true).start(); String out = new String(p.getInputStream().readAllBytes()).trim(); p.waitFor(); return out.isEmpty() ? null : out; } catch (Exception e) { return null; } } private void exec(Consumer emit, String... cmd) { try { Process p = new ProcessBuilder(cmd) .redirectErrorStream(true) .start(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { if (!line.isBlank()) emit.accept(" " + line); } } int code = p.waitFor(); if (code != 0) emit.accept(" [warn] exit code " + code); } catch (Exception e) { emit.accept(" [error] " + e.getMessage()); log.error("exec failed: {}", String.join(" ", cmd), e); } } }