2026-05-21 14:46:40 +08:00
|
|
|
|
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;
|
2026-05-21 16:26:01 +08:00
|
|
|
|
import java.nio.file.Files;
|
|
|
|
|
|
import java.nio.file.Paths;
|
2026-05-21 14:46:40 +08:00
|
|
|
|
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<String> 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<String> emit) {
|
|
|
|
|
|
String composeFile = deployRoot + "/docker-compose.yml";
|
|
|
|
|
|
|
2026-05-21 16:26:01 +08:00
|
|
|
|
// Authenticate to the registry before pulling (credentials stored in .env by deploy.sh)
|
|
|
|
|
|
dockerLogin(emit);
|
|
|
|
|
|
|
2026-05-21 14:46:40 +08:00
|
|
|
|
emit.accept(">>> 拉取最新镜像...");
|
|
|
|
|
|
for (String svc : OTHER_SERVICES) {
|
|
|
|
|
|
if (isRunning(svc)) {
|
|
|
|
|
|
emit.accept(" pulling " + svc + " ...");
|
2026-05-21 16:26:01 +08:00
|
|
|
|
exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", svc);
|
2026-05-21 14:46:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
emit.accept(" pulling tenant-service ...");
|
2026-05-21 16:26:01 +08:00
|
|
|
|
exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", "tenant-service");
|
2026-05-21 14:46:40 +08:00
|
|
|
|
emit.accept(">>> 镜像拉取完成");
|
|
|
|
|
|
|
|
|
|
|
|
emit.accept(">>> 重启各服务...");
|
|
|
|
|
|
for (String svc : OTHER_SERVICES) {
|
|
|
|
|
|
if (isRunning(svc)) {
|
|
|
|
|
|
emit.accept(" restarting " + svc + " ...");
|
2026-05-21 16:26:01 +08:00
|
|
|
|
exec(emit, "docker", "compose", "-f", composeFile,
|
2026-05-21 14:46:40 +08:00
|
|
|
|
"up", "-d", "--no-deps", "--force-recreate", svc);
|
|
|
|
|
|
emit.accept(" " + svc + " ✓");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 16:26:01 +08:00
|
|
|
|
// tenant-service 自身的重建:不能直接用 docker compose,因为一旦发出 stop 指令,
|
|
|
|
|
|
// 当前容器(含 docker compose 进程)会立即被杀死,后续的 rm/create/start 步骤不会执行。
|
2026-05-21 14:52:36 +08:00
|
|
|
|
// 解决方案:先用 docker run -d 启动一个独立助手容器,它不依附于 tenant-service,
|
|
|
|
|
|
// 能在 tenant-service 停止后继续完成重建。
|
|
|
|
|
|
emit.accept(">>> 启动自更新助手容器...");
|
2026-05-21 15:46:39 +08:00
|
|
|
|
String selfImage = getCurrentImage();
|
|
|
|
|
|
if (selfImage == null) {
|
|
|
|
|
|
emit.accept(">>> [错误] 无法获取当前 tenant-service 镜像名,请检查容器标签或手动执行更新。");
|
|
|
|
|
|
emit.accept("DONE");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-21 14:52:36 +08:00
|
|
|
|
boolean helperStarted = spawnSelfUpdater(composeFile, selfImage);
|
|
|
|
|
|
|
|
|
|
|
|
if (helperStarted) {
|
|
|
|
|
|
emit.accept(">>> 助手容器已就绪,tenant-service 即将重建(连接将短暂中断)...");
|
|
|
|
|
|
emit.accept("RESTART_SELF");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
emit.accept(">>> [警告] 助手容器启动失败,请手动执行:");
|
2026-05-21 16:26:01 +08:00
|
|
|
|
emit.accept(">>> docker compose -f " + composeFile + " up -d --no-deps --force-recreate tenant-service");
|
2026-05-21 14:52:36 +08:00
|
|
|
|
emit.accept("DONE");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 16:26:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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<String> 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());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 14:52:36 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 启动一个独立的 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
|
2026-05-21 16:26:01 +08:00
|
|
|
|
String shellCmd = "sleep 8 && docker compose -f " + composeFile
|
|
|
|
|
|
+ " up -d --no-deps --force-recreate tenant-service";
|
2026-05-21 14:52:36 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-05-21 14:46:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 16:26:01 +08:00
|
|
|
|
// Filter only by service label — avoids dependency on project name which varies by deploy root dir.
|
2026-05-21 14:46:40 +08:00
|
|
|
|
private boolean isRunning(String service) {
|
|
|
|
|
|
try {
|
2026-05-21 15:46:39 +08:00
|
|
|
|
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();
|
2026-05-21 14:46:40 +08:00
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 15:46:39 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 14:46:40 +08:00
|
|
|
|
private void exec(Consumer<String> 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|