feat(updater): support updates after verified SeaweedFS migration

This commit is contained in:
2026-09-28 15:47:06 +02:00
parent 790f20b499
commit 2b947c5e8f
20 changed files with 1074 additions and 67 deletions

View File

@@ -36,6 +36,7 @@ const state = z.object({
const plan = state.omit({ status: true }).extend({
updateAvailable: z.boolean(), requiresComposeAdoption: z.boolean(),
estimatedBackupBytes: z.number().nonnegative().finite().optional(),
storageProvider: z.enum(["minio", "seaweedfs"]).optional(), storageImageRetained: z.boolean().optional(),
retiredServices: z.array(z.enum(["matrix-db", "matrix-synapse", "matrix-redis", "matrix-element",
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"])),
});
@@ -48,6 +49,7 @@ const job = state.extend({ plan: plan.optional(), progress: state.optional(),
const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state,
installed: state.pick({ version: true, revision: true }), request: job.optional(),
capabilities: z.object({ requestLogs: z.boolean() }).optional(),
storage: z.object({ provider: z.literal("seaweedfs"), migrationPending: z.boolean() }).optional(),
});
const logs = z.object({ requestId, logs: z.array(z.object({
source: z.enum(["service", "events", "commands"]), content: z.string().max(6000), truncated: z.boolean(),

View File

@@ -1,4 +1,7 @@
import { FastifyInstance } from "fastify"
import { HeadBucketCommand } from "@aws-sdk/client-s3"
import { s3 } from "../utils/s3"
import { secrets } from "../utils/secrets"
type MetricSample = {
labels: Record<string, string>
@@ -10,9 +13,6 @@ const metricLinePattern = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\
const nodeExporterUrl = () =>
(process.env.NODE_EXPORTER_URL || "http://node-exporter:9100").replace(/\/+$/, "")
const s3EndpointUrl = () =>
(process.env.S3_ENDPOINT || "").replace(/\/+$/, "")
const parseLabels = (value = "") => {
const labels: Record<string, string> = {}
const labelPattern = /(\w+)="((?:\\"|[^"])*)"/g
@@ -63,20 +63,17 @@ const serviceState = (ok: boolean, detail?: Record<string, any>) => ({
...detail,
})
const checkHttp = async (url: string, timeoutMs = 3000) => {
export const checkFileStorage = async (client = s3, bucket = secrets.S3_BUCKET, timeoutMs = 3000) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(url, { signal: controller.signal })
return serviceState(response.ok, {
httpStatus: response.status,
url,
})
} catch (err: any) {
if (!client || !bucket) return serviceState(false, { error: "S3-Dateispeicher ist nicht eingerichtet" })
await client.send(new HeadBucketCommand({ Bucket: bucket }), { abortSignal: controller.signal })
return serviceState(true, { provider: process.env.FEDEO_STORAGE_PROVIDER === "seaweedfs" ? "seaweedfs" : "s3" })
} catch {
return serviceState(false, {
url,
error: err?.message || "HTTP-Abfrage fehlgeschlagen",
error: controller.signal.aborted ? "S3-Prüfung hat das Zeitlimit überschritten" : "S3-Bucket nicht erreichbar oder Zugriff verweigert",
})
} finally {
clearTimeout(timeout)
@@ -116,7 +113,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null
const databaseCheck = await server.db.execute("SELECT NOW() as now")
const minioUrl = s3EndpointUrl()
return {
checkedAt: checkedAt.toISOString(),
@@ -160,9 +156,8 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
url: nodeExporterMetricsUrl,
error: nodeExporterError,
}),
minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, {
error: "S3_ENDPOINT ist nicht gesetzt",
}),
// Preserve the response key for older clients; check the authenticated S3 API for every provider.
minio: await checkFileStorage(),
},
}
}

View File

@@ -128,6 +128,20 @@ test("Unix client bounds response size, deadline, and protocol shape", async t =
});
}
});
test("provider status and plan cross the socket boundary without private storage configuration", async t => {
const client = await socketFixture(t, (_req, res) => res.end(JSON.stringify({
apiVersion: 1, acceptingRequests: true, engine: { status: "recovered", phase: "resolved" }, installed: {},
storage: { provider: "seaweedfs", migrationPending: false, service: { secret: "hidden" } },
request: { status: "checked", plan: { updateAvailable: true, requiresComposeAdoption: false, retiredServices: [],
storageProvider: "seaweedfs", storageImageRetained: true, storageBackupPaths: ["/private/path"] } },
})));
const { body } = await client("GET", "/v1/status") as { body: any };
assert.deepEqual(body.storage, { provider: "seaweedfs", migrationPending: false });
assert.equal(body.request.plan.storageProvider, "seaweedfs");
assert.equal(body.request.plan.storageImageRetained, true);
assert.ok(!JSON.stringify(body).includes("hidden"));
assert.ok(!JSON.stringify(body).includes("private/path"));
});
test("Unix client validates bounded log responses and their request association", async t => {
let payload: any = { requestId: id, logs: [{ source: "commands", content: "pull access denied", truncated: true,
path: "/private/commands.log" }], private: "omitted" };

View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import { HeadBucketCommand } from "@aws-sdk/client-s3";
import { checkFileStorage } from "../src/modules/system-status.service";
test("file storage health uses an authenticated S3 bucket request for every provider", async () => {
const client = { send: async (command: any, options: any) => {
assert.ok(command instanceof HeadBucketCommand);
assert.deepEqual(command.input, { Bucket: "fedeo" });
assert.ok(options.abortSignal instanceof AbortSignal);
} };
assert.equal((await checkFileStorage(client, "fedeo")).ok, true);
});
test("unconfigured or denied S3 access is unhealthy and does not disclose SDK details", async () => {
assert.equal((await checkFileStorage(null, "fedeo")).ok, false);
const result = await checkFileStorage({ send: async () => { throw new Error("secret@private-host"); } }, "fedeo");
assert.equal(result.ok, false);
assert.ok(!JSON.stringify(result).includes("private-host"));
});
test("S3 health aborts a hung request at its deadline", async () => {
const client = { send: (_command: any, options: any) => new Promise((_resolve, reject) => {
options.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
}) };
const result = await checkFileStorage(client, "fedeo", 10);
assert.equal(result.ok, false);
assert.match(result.error, /Zeitlimit/);
});