This commit is contained in:
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
+79
-7
@@ -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) {
|
||||
|
||||
+75
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user