fix: make online updates observable and recoverable
Release / release (push) Canceled after 0s

This commit is contained in:
Qiufeng
2026-08-18 12:56:14 +08:00
parent 10d2e2f0d1
commit cea7048f6c
17 changed files with 1138 additions and 67 deletions
@@ -0,0 +1,11 @@
package com.kaidi.finance.update.api;
import java.time.Instant;
public record SystemUpdateEventView(
Instant occurredAt,
String level,
String stage,
String message
) {
}
@@ -14,6 +14,12 @@ public record SystemUpdateView(
Instant publishedAt,
Instant checkedAt,
Instant statusUpdatedAt,
Long downloadedBytes,
Long totalBytes,
Long bytesPerSecond,
Integer downloadPercent,
Integer restartExpectedSeconds,
List<SystemUpdateEventView> events,
List<String> allowedActions
) {
}
@@ -11,6 +11,7 @@ import com.kaidi.finance.shared.security.AuthorizationService;
import com.kaidi.finance.shared.security.IdentityContext;
import com.kaidi.finance.update.api.SystemUpdateContracts.DownloadUpdateRequest;
import com.kaidi.finance.update.api.SystemUpdateContracts.InstallUpdateRequest;
import com.kaidi.finance.update.api.SystemUpdateEventView;
import com.kaidi.finance.update.api.SystemUpdateView;
import java.io.IOException;
import java.io.InputStream;
@@ -44,6 +45,8 @@ public class SystemUpdateApplicationService {
private static final int MAX_MANIFEST_BYTES = 64 * 1024;
private static final int MAX_RELEASE_API_BYTES = 256 * 1024;
private static final int MAX_STATUS_BYTES = 64 * 1024;
private static final int MAX_EVENT_LOG_BYTES = 256 * 1024;
private static final int MAX_EVENT_COUNT = 120;
private static final int MAX_REDIRECTS = 3;
private static final String PRERELEASE_IDENTIFIER =
"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)";
@@ -57,8 +60,8 @@ public class SystemUpdateApplicationService {
"^(.*/)?api/v1/repos/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/releases/(?:latest|tags/[^/?#]+)$");
private static final Pattern GITEA_RELEASE_TAG = Pattern.compile("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$");
private static final List<String> BUSY_STATES = List.of(
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "VERIFYING", "DOWNLOADING", "BACKING_UP",
"INSTALLING", "RUNNING");
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "PRECHECKING", "VERIFYING", "DOWNLOADING",
"BACKING_UP", "INSTALLING", "RUNNING");
private final SystemUpdateProperties properties;
private final AuthorizationService authorizationService;
@@ -353,6 +356,10 @@ public class SystemUpdateApplicationService {
try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
FileLock ignored = channel.lock()) {
UpdateStatus currentStatus = readStatus();
if ("RECOVERY_REQUIRED".equals(currentStatus.state())) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS,
"更新服务需要人工恢复,暂不接受新的更新请求");
}
if (BUSY_STATES.contains(currentStatus.state())) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS,
"已有系统更新任务正在执行");
@@ -421,7 +428,10 @@ public class SystemUpdateApplicationService {
String state = root.path("state").asText("UNKNOWN").toUpperCase(Locale.ROOT);
if (!state.matches("^[A-Z_]{2,32}$")) state = "UNKNOWN";
return new UpdateStatus(state, root.path("message").asText(""),
blank(root.path("targetVersion").asText(null)), parseInstant(root.path("updatedAt").asText(null)));
blank(root.path("targetVersion").asText(null)), parseInstant(root.path("updatedAt").asText(null)),
nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"),
nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100),
boundedInteger(root, "restartExpectedSeconds", 0, 300));
} catch (IOException exception) {
return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null);
}
@@ -433,17 +443,60 @@ public class SystemUpdateApplicationService {
String candidateVersion = manifest == null ? status.targetVersion() : manifest.version();
boolean available = candidateVersion != null && compareVersions(candidateVersion, current) > 0;
boolean busy = BUSY_STATES.contains(status.state());
boolean recoveryRequired = "RECOVERY_REQUIRED".equals(status.state());
boolean pending = hasPendingRequest(properties.requestFile().toAbsolutePath().normalize());
boolean ready = "READY".equals(status.state()) && candidateVersion != null
&& candidateVersion.equals(status.targetVersion());
List<String> actions = new ArrayList<>();
if (enabled && !busy && !pending) actions.add("CHECK");
if (enabled && available && !busy && !pending && authorizationService.hasPermission("admin:update:execute")) {
if (enabled && !busy && !pending && !recoveryRequired) actions.add("CHECK");
if (enabled && available && !busy && !pending && !recoveryRequired
&& authorizationService.hasPermission("admin:update:execute")) {
actions.add(ready ? "INSTALL" : "DOWNLOAD");
}
return new SystemUpdateView(enabled, current, candidateVersion,
available, status.state(), status.message(), manifest == null ? null : manifest.releaseNotes(),
manifest == null ? null : manifest.publishedAt(), lastCheckedAt, status.updatedAt(), List.copyOf(actions));
manifest == null ? null : manifest.publishedAt(), lastCheckedAt, status.updatedAt(),
status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(),
status.restartExpectedSeconds(), readUpdateEvents(), List.copyOf(actions));
}
private List<SystemUpdateEventView> readUpdateEvents() {
Path statusFile = properties.statusFile().toAbsolutePath().normalize();
Path parent = statusFile.getParent();
if (parent == null) return List.of();
Path eventFile = parent.resolve("events.jsonl").normalize();
if (!eventFile.startsWith(parent) || !Files.isRegularFile(eventFile, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(eventFile)) {
return List.of();
}
try {
if (Files.size(eventFile) > MAX_EVENT_LOG_BYTES) return List.of();
List<String> lines = Files.readAllLines(eventFile, StandardCharsets.UTF_8);
int first = Math.max(0, lines.size() - MAX_EVENT_COUNT);
List<SystemUpdateEventView> events = new ArrayList<>();
for (int index = first; index < lines.size(); index++) {
String line = lines.get(index);
if (line.isBlank() || line.length() > 4096) continue;
try {
JsonNode event = objectMapper.readTree(line);
Instant occurredAt = parseInstant(event.path("occurredAt").asText(null));
String level = event.path("level").asText("INFO").toUpperCase(Locale.ROOT);
String stage = event.path("stage").asText("UNKNOWN").toUpperCase(Locale.ROOT);
String message = event.path("message").asText("");
if (occurredAt == null || !level.matches("^(INFO|WARN|ERROR)$")
|| !stage.matches("^[A-Z_]{2,32}$") || message.isBlank()) {
continue;
}
events.add(new SystemUpdateEventView(occurredAt, level, stage,
message.length() > 1000 ? message.substring(0, 1000) : message));
} catch (IOException ignored) {
// Ignore a partially written event line.
}
}
return List.copyOf(events);
} catch (IOException exception) {
return List.of();
}
}
private boolean hasPendingRequest(Path requestFile) {
@@ -564,6 +617,20 @@ public class SystemUpdateApplicationService {
}
}
private static Long nonNegativeLong(JsonNode root, String field) {
JsonNode value = root.path(field);
if (!value.canConvertToLong()) return null;
long parsed = value.asLong();
return parsed < 0 ? null : parsed;
}
private static Integer boundedInteger(JsonNode root, String field, int minimum, int maximum) {
JsonNode value = root.path(field);
if (!value.canConvertToInt()) return null;
int parsed = value.asInt();
return parsed < minimum || parsed > maximum ? null : parsed;
}
private BusinessException validation(String message) {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
}
@@ -580,7 +647,12 @@ public class SystemUpdateApplicationService {
String releaseNotes) {
}
private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
Long downloadedBytes, Long totalBytes, Long bytesPerSecond,
Integer downloadPercent, Integer restartExpectedSeconds) {
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
this(state, message, targetVersion, updatedAt, null, null, null, null, null);
}
}
private record QueueTransition(UpdateStatus previous, UpdateStatus queued) {
@@ -211,6 +211,81 @@ class SystemUpdateApplicationServiceTest {
}
}
@Test
void restoresDownloadMetricsAndRuntimeEventsFromUpdaterState() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
Path inbox = Files.createDirectory(tempDir.resolve("progress-inbox"));
Path statusFile = tempDir.resolve("progress-status.json");
Files.writeString(statusFile, """
{"state":"DOWNLOADING","message":"Downloading signed release","targetVersion":"1.0.0-preview.2",
"updatedAt":"2026-08-18T03:00:00Z","downloadedBytes":5242880,"totalBytes":10485760,
"bytesPerSecond":1048576,"downloadPercent":50,"restartExpectedSeconds":10}
""");
Files.writeString(tempDir.resolve("events.jsonl"), """
{"occurredAt":"2026-08-18T02:59:59Z","level":"INFO","stage":"VERIFYING","message":"签名校验开始"}
{"occurredAt":"2026-08-18T03:00:00Z","level":"INFO","stage":"DOWNLOADING","message":"更新包下载中"}
""");
SystemUpdateApplicationService service = serviceForGitea(server, inbox, statusFile);
var status = service.status();
assertEquals("DOWNLOADING", status.state());
assertEquals(5_242_880L, status.downloadedBytes());
assertEquals(10_485_760L, status.totalBytes());
assertEquals(1_048_576L, status.bytesPerSecond());
assertEquals(50, status.downloadPercent());
assertEquals(10, status.restartExpectedSeconds());
assertEquals(2, status.events().size());
assertEquals("DOWNLOADING", status.events().get(1).stage());
assertFalse(status.allowedActions().contains("DOWNLOAD"));
}
@Test
void recoveryRequiredStateBlocksEveryUpdateAction() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> {
String assetUrl = "http://127.0.0.1:" + server.getAddress().getPort()
+ "/ERP-Team/kaidi/releases/download/v1.0.0-preview.2/release-manifest.json";
byte[] body = """
{"tag_name":"v1.0.0-preview.2","assets":[
{"name":"release-manifest.json","browser_download_url":"%s"}]}
""".formatted(assetUrl).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.createContext("/ERP-Team/kaidi/releases/download/v1.0.0-preview.2/release-manifest.json",
exchange -> {
byte[] body = """
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
""".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
Path inbox = Files.createDirectory(tempDir.resolve("recovery-inbox"));
Path statusFile = tempDir.resolve("recovery-status.json");
Files.writeString(statusFile, """
{"state":"RECOVERY_REQUIRED","message":"自动回滚未完成","targetVersion":"1.0.0-preview.2",
"updatedAt":"2026-08-18T03:10:00Z"}
""");
SystemUpdateApplicationService service = serviceForGitea(server, inbox, statusFile);
try {
var status = service.status();
assertEquals("RECOVERY_REQUIRED", status.state());
assertTrue(status.allowedActions().isEmpty());
assertThrows(BusinessException.class,
() -> service.download(new DownloadUpdateRequest("1.0.0-preview.2")));
assertFalse(Files.exists(inbox.resolve("request.json")));
} finally {
server.stop(0);
}
}
@Test
void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);