Replace compose-file-path-dependent `docker compose -f <path>` calls with label-based `docker ps` queries so the ops log viewer works on both public cloud and private deployments regardless of compose file location. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
525 行
24 KiB
Java
525 行
24 KiB
Java
package com.xuqm.tenant.service;
|
||
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||
import org.springframework.context.event.EventListener;
|
||
import org.springframework.stereotype.Service;
|
||
|
||
import javax.sql.DataSource;
|
||
import java.io.BufferedReader;
|
||
import java.io.IOException;
|
||
import java.io.InputStreamReader;
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.nio.file.Files;
|
||
import java.nio.file.Path;
|
||
import java.nio.file.Paths;
|
||
import java.nio.file.StandardOpenOption;
|
||
import java.sql.Connection;
|
||
import java.sql.PreparedStatement;
|
||
import java.sql.ResultSet;
|
||
import java.sql.Statement;
|
||
import java.util.Arrays;
|
||
import java.util.List;
|
||
import java.util.Set;
|
||
import java.util.function.Consumer;
|
||
import java.util.stream.Collectors;
|
||
|
||
@Service
|
||
public class SystemUpdateService {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(SystemUpdateService.class);
|
||
|
||
// nginx 最后重启,确保它能获取到其他服务修复后的配置
|
||
private static final List<String> OTHER_SERVICES = List.of(
|
||
"file-service", "tenant-web", "im-service", "push-service", "update-service", "license-service", "nginx"
|
||
);
|
||
|
||
private static final Set<String> ALLOWED_LOG_SERVICES = Set.of(
|
||
"tenant-service", "file-service", "im-service", "push-service",
|
||
"update-service", "license-service", "nginx", "tenant-web"
|
||
);
|
||
|
||
@Value("${PRIVATE_DEPLOY_ROOT:/opt/xuqm-private}")
|
||
private String deployRoot;
|
||
|
||
private final DataSource dataSource;
|
||
|
||
public SystemUpdateService(DataSource dataSource) {
|
||
this.dataSource = dataSource;
|
||
}
|
||
|
||
// ── 启动时自动执行迁移 ──────────────────────────────────────────────────────
|
||
|
||
@EventListener(ApplicationReadyEvent.class)
|
||
public void onApplicationReady() {
|
||
runSchemaMigrations(line -> log.info("[migration] {}", line));
|
||
}
|
||
|
||
// ── 公开接口 ────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 返回当前正在运行的服务名列表。
|
||
* 使用 docker ps --format "{{.Label \"com.docker.compose.service\"}}" 枚举运行中容器的服务标签,
|
||
* 不依赖 compose 文件路径,公有云/私有云均可用。
|
||
* 结果已过滤为 ALLOWED_LOG_SERVICES 白名单内的服务。
|
||
*/
|
||
public List<String> getRunningServices() {
|
||
try {
|
||
Process p = new ProcessBuilder(
|
||
"docker", "ps",
|
||
"--format", "{{.Label \"com.docker.compose.service\"}}"
|
||
).redirectErrorStream(true).start();
|
||
String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim();
|
||
p.waitFor();
|
||
if (out.isEmpty()) return List.of();
|
||
return Arrays.stream(out.split("\n"))
|
||
.map(String::trim)
|
||
.filter(s -> !s.isEmpty() && ALLOWED_LOG_SERVICES.contains(s))
|
||
.distinct()
|
||
.collect(Collectors.toList());
|
||
} catch (Exception e) {
|
||
log.error("failed to list running services", e);
|
||
return List.of();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取指定服务最近 N 行日志(上限 1000)。
|
||
* 通过 docker ps 标签找到容器 ID 后使用 docker logs,不依赖 compose 文件路径。
|
||
*/
|
||
public String getServiceLogs(String service, int lines) {
|
||
if (!ALLOWED_LOG_SERVICES.contains(service)) {
|
||
throw new IllegalArgumentException("不允许查看此服务的日志: " + service);
|
||
}
|
||
int safeLines = Math.min(Math.max(lines, 10), 1000);
|
||
try {
|
||
// 找到对应服务的容器 ID(可能有多个,取第一个)
|
||
Process psProc = new ProcessBuilder(
|
||
"docker", "ps",
|
||
"--filter", "label=com.docker.compose.service=" + service,
|
||
"--format", "{{.ID}}"
|
||
).redirectErrorStream(true).start();
|
||
String containerId = new String(psProc.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim();
|
||
psProc.waitFor();
|
||
|
||
if (containerId.isEmpty()) {
|
||
return "(服务 " + service + " 当前没有运行中的容器)";
|
||
}
|
||
// 取第一行(如有多个容器)
|
||
String firstId = containerId.split("\n")[0].trim();
|
||
|
||
Process logsProc = new ProcessBuilder(
|
||
"docker", "logs", "--tail", String.valueOf(safeLines), "--timestamps", firstId
|
||
).redirectErrorStream(true).start();
|
||
String out = new String(logsProc.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||
int exitCode = logsProc.waitFor();
|
||
if (exitCode != 0 && out.isBlank()) {
|
||
throw new RuntimeException("docker logs 返回非零退出码: " + exitCode);
|
||
}
|
||
return out;
|
||
} catch (IllegalArgumentException e) {
|
||
throw e;
|
||
} catch (Exception e) {
|
||
log.error("failed to fetch logs for service {}", service, e);
|
||
throw new RuntimeException("获取日志失败: " + e.getMessage());
|
||
}
|
||
}
|
||
|
||
/** 读取部署目录的 VERSION 文件,返回当前版本号,文件不存在时返回 "unknown"。 */
|
||
public String readCurrentVersion() {
|
||
Path versionFile = Paths.get(deployRoot, "VERSION");
|
||
try {
|
||
if (Files.exists(versionFile)) {
|
||
return Files.readString(versionFile).trim();
|
||
}
|
||
} catch (IOException ignored) {}
|
||
return "unknown";
|
||
}
|
||
|
||
/** 拉取最新镜像并重建所有容器。 */
|
||
public void runUpdate(Consumer<String> emit) {
|
||
String composeFile = deployRoot + "/docker-compose.yml";
|
||
|
||
dockerLogin(emit);
|
||
patchConfigs(emit);
|
||
runSchemaMigrations(emit);
|
||
|
||
emit.accept(">>> 拉取最新镜像...");
|
||
for (String svc : OTHER_SERVICES) {
|
||
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(">>> 镜像拉取完成");
|
||
|
||
restartAndSelfUpdate(emit, composeFile);
|
||
}
|
||
|
||
/** 不拉取新镜像,直接用当前本地镜像重建所有容器。 */
|
||
public void runReset(Consumer<String> emit) {
|
||
String composeFile = deployRoot + "/docker-compose.yml";
|
||
|
||
patchConfigs(emit);
|
||
runSchemaMigrations(emit);
|
||
restartAndSelfUpdate(emit, composeFile);
|
||
}
|
||
|
||
// ── Schema 版本化迁移 ───────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 执行所有待处理的 schema 迁移。
|
||
*
|
||
* 迁移原则:
|
||
* - ddl-auto:update 自动处理新增列/表,此处仅处理 Hibernate 无法完成的变更
|
||
* (删列、改列名、类型转换、数据填充等)
|
||
* - 每个迁移有唯一 ID,执行后记录到 _schema_migrations,保证幂等
|
||
* - 新版本新增迁移时,在末尾追加新的 migrate_xxx() 调用即可
|
||
*/
|
||
public void runSchemaMigrations(Consumer<String> emit) {
|
||
emit.accept(">>> 检查数据库迁移...");
|
||
try {
|
||
ensureMigrationsTable();
|
||
} catch (Exception e) {
|
||
emit.accept(" [警告] 无法初始化迁移记录表: " + e.getMessage());
|
||
return;
|
||
}
|
||
|
||
migrate_v20260101_drop_device_id_unique_index(emit);
|
||
// 新版本迁移在此追加,例如:
|
||
// migrate_v20260601_add_app_extra_column(emit);
|
||
|
||
emit.accept(">>> 数据库迁移检查完成");
|
||
}
|
||
|
||
private void ensureMigrationsTable() throws Exception {
|
||
try (Connection conn = dataSource.getConnection();
|
||
Statement stmt = conn.createStatement()) {
|
||
stmt.execute("""
|
||
CREATE TABLE IF NOT EXISTS _schema_migrations (
|
||
id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
description VARCHAR(255)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||
""");
|
||
}
|
||
}
|
||
|
||
private boolean migrationApplied(String id) {
|
||
try (Connection conn = dataSource.getConnection();
|
||
PreparedStatement ps = conn.prepareStatement(
|
||
"SELECT COUNT(*) FROM _schema_migrations WHERE id = ?")) {
|
||
ps.setString(1, id);
|
||
try (ResultSet rs = ps.executeQuery()) {
|
||
return rs.next() && rs.getInt(1) > 0;
|
||
}
|
||
} catch (Exception e) {
|
||
log.warn("check migration {} failed: {}", id, e.getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private void recordMigration(String id, String description) {
|
||
try (Connection conn = dataSource.getConnection();
|
||
PreparedStatement ps = conn.prepareStatement(
|
||
"INSERT IGNORE INTO _schema_migrations (id, description) VALUES (?, ?)")) {
|
||
ps.setString(1, id);
|
||
ps.setString(2, description);
|
||
ps.executeUpdate();
|
||
} catch (Exception e) {
|
||
log.warn("record migration {} failed: {}", id, e.getMessage());
|
||
}
|
||
}
|
||
|
||
// ── 各版本迁移 ──────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* license-service DeviceEntity 上的 column-level unique=true 在多租户场景下产生了跨 appKey 的
|
||
* 全局唯一约束,与正确的复合唯一索引 uk_app_key_device_id(app_key, device_id) 冲突。
|
||
* Hibernate ddl-auto:update 不删除多余约束,必须手动 ALTER TABLE。
|
||
* 根治方案:已同步移除 DeviceEntity.deviceId 上的 unique=true 注解,新安装不再产生该约束。
|
||
*/
|
||
private void migrate_v20260101_drop_device_id_unique_index(Consumer<String> emit) {
|
||
final String id = "v20260101_drop_device_id_unique_index";
|
||
if (migrationApplied(id)) {
|
||
emit.accept(" [已应用] " + id);
|
||
return;
|
||
}
|
||
try (Connection conn = dataSource.getConnection()) {
|
||
boolean exists;
|
||
try (PreparedStatement ps = conn.prepareStatement("""
|
||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||
WHERE TABLE_SCHEMA = DATABASE()
|
||
AND TABLE_NAME = 'devices'
|
||
AND INDEX_NAME = 'device_id'
|
||
AND NON_UNIQUE = 0
|
||
""");
|
||
ResultSet rs = ps.executeQuery()) {
|
||
exists = rs.next() && rs.getInt(1) > 0;
|
||
}
|
||
if (exists) {
|
||
try (Statement stmt = conn.createStatement()) {
|
||
stmt.execute("ALTER TABLE devices DROP INDEX device_id");
|
||
}
|
||
emit.accept(" [已迁移] " + id + ": 删除 devices.device_id 旧单列唯一约束");
|
||
} else {
|
||
emit.accept(" [已迁移] " + id + ": devices.device_id 单列约束不存在,无需处理");
|
||
}
|
||
recordMigration(id, "删除 devices 表 device_id 旧单列唯一约束");
|
||
} catch (Exception e) {
|
||
emit.accept(" [错误] " + id + ": " + e.getMessage());
|
||
log.error("migration {} failed", id, e);
|
||
}
|
||
}
|
||
|
||
// ── 重启核心 ────────────────────────────────────────────────────────────────
|
||
|
||
private void restartAndSelfUpdate(Consumer<String> emit, String composeFile) {
|
||
emit.accept(">>> 重建各服务容器...");
|
||
for (String svc : OTHER_SERVICES) {
|
||
emit.accept(" restarting " + svc + " ...");
|
||
exec(emit, "docker", "compose", "-f", composeFile,
|
||
"up", "-d", "--no-deps", "--force-recreate", svc);
|
||
emit.accept(" " + svc + " ✓");
|
||
}
|
||
|
||
emit.accept(">>> 启动自更新助手容器...");
|
||
String selfImage = getCurrentImage();
|
||
if (selfImage == null) {
|
||
emit.accept(">>> [错误] 无法获取当前 tenant-service 镜像名,请手动执行:");
|
||
emit.accept(">>> docker compose -f " + composeFile + " up -d --no-deps --force-recreate 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");
|
||
}
|
||
}
|
||
|
||
// ── 配置文件热修复 ──────────────────────────────────────────────────────────
|
||
|
||
private void patchConfigs(Consumer<String> emit) {
|
||
emit.accept(">>> 检查并修复配置文件...");
|
||
patchNginxFileRoute(emit);
|
||
patchNginxUpdateTimeout(emit);
|
||
patchDockerComposeFileService(emit);
|
||
patchDockerComposeUpdateService(emit);
|
||
}
|
||
|
||
private void patchNginxUpdateTimeout(Consumer<String> emit) {
|
||
Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf");
|
||
if (!Files.exists(conf)) return;
|
||
try {
|
||
String content = Files.readString(conf);
|
||
if (content.contains("location ~ ^/api/system/") || content.contains("location = /api/system/update")) return;
|
||
String anchor = " # 核心 API(兜底,在所有具体 /api/xxx/ 之后)\n location /api/ {";
|
||
if (!content.contains(anchor)) {
|
||
emit.accept(" [跳过] nginx 更新超时补丁锚点未找到,请手动检查");
|
||
return;
|
||
}
|
||
String injection = " # 一键更新/重置:操作耗时较长,需要更长超时(精确匹配,优先于 /api/ 前缀)\n"
|
||
+ " location ~ ^/api/system/(update|reset)$ {\n"
|
||
+ " set $svc tenant-service;\n"
|
||
+ " proxy_pass http://$svc:9001;\n"
|
||
+ " proxy_set_header Host $host;\n"
|
||
+ " proxy_set_header X-Real-IP $remote_addr;\n"
|
||
+ " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"
|
||
+ " proxy_read_timeout 600s;\n"
|
||
+ " proxy_send_timeout 600s;\n"
|
||
+ " }\n\n"
|
||
+ anchor;
|
||
Files.writeString(conf, content.replace(anchor, injection), StandardOpenOption.TRUNCATE_EXISTING);
|
||
emit.accept(" [已修复] nginx: 补齐 /api/system/(update|reset) 600s 超时");
|
||
} catch (IOException e) {
|
||
emit.accept(" [警告] nginx 更新超时修复失败: " + e.getMessage());
|
||
}
|
||
}
|
||
|
||
private void patchNginxFileRoute(Consumer<String> emit) {
|
||
Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf");
|
||
if (!Files.exists(conf)) return;
|
||
try {
|
||
String content = Files.readString(conf);
|
||
if (!content.contains("location /file/")) return;
|
||
Files.writeString(conf, content.replace("location /file/", "location /api/file/"),
|
||
StandardOpenOption.TRUNCATE_EXISTING);
|
||
emit.accept(" [已修复] nginx: location /file/ → /api/file/");
|
||
} catch (IOException e) {
|
||
emit.accept(" [警告] nginx 配置修复失败: " + e.getMessage());
|
||
}
|
||
}
|
||
|
||
private void patchDockerComposeFileService(Consumer<String> emit) {
|
||
Path composeFile = Paths.get(deployRoot, "docker-compose.yml");
|
||
if (!Files.exists(composeFile)) return;
|
||
try {
|
||
String content = Files.readString(composeFile);
|
||
if (content.contains("FILE_UPLOAD_DIR") && content.contains("FILE_BASE_URL")) return;
|
||
|
||
String consoleDomain = readEnvValue(Paths.get(deployRoot, "config", "xuqm.env"), "CONSOLE_DOMAIN");
|
||
if (consoleDomain == null) consoleDomain = "";
|
||
|
||
String anchor = " SPRING_DATA_REDIS_DATABASE: \"${REDIS_DATABASE:-0}\"\n";
|
||
if (!content.contains(anchor)) {
|
||
emit.accept(" [跳过] docker-compose 文件服务补丁锚点未找到,请手动检查");
|
||
return;
|
||
}
|
||
String injection = anchor
|
||
+ " FILE_UPLOAD_DIR: \"/data/uploads\"\n"
|
||
+ " FILE_BASE_URL: \"" + consoleDomain + "\"\n";
|
||
Files.writeString(composeFile, content.replace(anchor, injection), StandardOpenOption.TRUNCATE_EXISTING);
|
||
emit.accept(" [已修复] docker-compose: 补齐 FILE_UPLOAD_DIR 和 FILE_BASE_URL");
|
||
} catch (IOException e) {
|
||
emit.accept(" [警告] docker-compose 修复失败: " + e.getMessage());
|
||
}
|
||
}
|
||
|
||
private void patchDockerComposeUpdateService(Consumer<String> emit) {
|
||
Path composeFile = Paths.get(deployRoot, "docker-compose.yml");
|
||
if (!Files.exists(composeFile)) return;
|
||
try {
|
||
String content = Files.readString(composeFile);
|
||
if (content.contains("FILE_SERVICE_INTERNAL_URL")) return;
|
||
|
||
String consoleDomain = readEnvValue(Paths.get(deployRoot, "config", "xuqm.env"), "CONSOLE_DOMAIN");
|
||
if (consoleDomain == null) consoleDomain = "";
|
||
|
||
String envBlock = " FILE_BASE_URL: \"" + consoleDomain + "\"\n"
|
||
+ " FILE_SERVICE_INTERNAL_URL: \"http://file-service:8086\"\n";
|
||
String anchor = " SDK_TENANT_SERVICE_URL: \"http://tenant-service:9001\"\n";
|
||
String fallbackAnchor = " image: ${REGISTRY}/update-service:${IMAGE_TAG}\n";
|
||
|
||
String patched;
|
||
if (content.contains(anchor)) {
|
||
patched = content.replace(anchor, anchor + envBlock);
|
||
} else if (content.contains(fallbackAnchor)) {
|
||
String envAnchor = fallbackAnchor + " environment:\n";
|
||
if (!content.contains(envAnchor)) {
|
||
emit.accept(" [跳过] docker-compose update-service environment 段未找到,请手动检查");
|
||
return;
|
||
}
|
||
patched = content.replace(envAnchor, envAnchor + envBlock);
|
||
} else {
|
||
emit.accept(" [跳过] docker-compose update-service 段未找到,请手动检查");
|
||
return;
|
||
}
|
||
Files.writeString(composeFile, patched, StandardOpenOption.TRUNCATE_EXISTING);
|
||
emit.accept(" [已修复] docker-compose: 补齐 update-service 的 FILE_BASE_URL 和 FILE_SERVICE_INTERNAL_URL");
|
||
} catch (IOException e) {
|
||
emit.accept(" [警告] docker-compose update-service 修复失败: " + e.getMessage());
|
||
}
|
||
}
|
||
|
||
// ── Docker 工具方法 ─────────────────────────────────────────────────────────
|
||
|
||
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;
|
||
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());
|
||
}
|
||
}
|
||
|
||
private boolean spawnSelfUpdater(String composeFile, String image) {
|
||
try {
|
||
new ProcessBuilder("docker", "rm", "-f", "xuqm-self-updater")
|
||
.redirectErrorStream(true).start().waitFor();
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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 String readEnvValue(Path envFile, String key) {
|
||
if (!Files.exists(envFile)) return null;
|
||
try {
|
||
for (String line : Files.readAllLines(envFile)) {
|
||
if (line.startsWith(key + "=")) {
|
||
return line.substring(key.length() + 1).trim();
|
||
}
|
||
}
|
||
} catch (IOException ignored) {}
|
||
return null;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|