fix(backups): подтверждать настройки и последнюю проверку S3

This commit is contained in:
Denozordec committed 2026-10-01 15:37:00 +07:00
1 parent 8e01666e0e
commit 30bd479a6f
14 files changed
+208 -64

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts",
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
"test:backups": "tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
"test:backups": "tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts",
+14
View File
@@ -0,0 +1,14 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { mapBackupEntry } from "./backup-dto.js"
const row = { id: "backup", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01 07:00:00.123+07", kind: "manual" as const, notes: null, storage: "local" as const, s3Key: null, s3Etag: null, uploadError: null }
test("PostgreSQL timestamp format is normalized to ISO with the same instant", () => {
const result = mapBackupEntry(row); assert.equal(result.createdAt, "2026-10-01T00:00:00.123Z"); assert.equal(result.sizeBytes, 0); assert.equal(result.serverId, 1)
})
test("removed server and partial S3 failure remain explicit", () => {
const result = mapBackupEntry({ ...row, serverId: null, uploadError: "S3 unavailable" })
assert.equal(result.serverId, null); assert.equal(result.storage, "local"); assert.equal(result.uploadError, "S3 unavailable")
})
test("invalid timestamps and sizes do not turn into truthful backup metadata", () => {
assert.throws(() => mapBackupEntry({ ...row, createdAt: "bad" })); assert.throws(() => mapBackupEntry({ ...row, sizeBytes: -1 }))
})
+9
View File
@@ -0,0 +1,9 @@
import { backupItemDtoSchema } from "@mmapp/contracts/backups"
import type { backupEntries } from "../db/schema.js"
export function mapBackupEntry(row: typeof backupEntries.$inferSelect) {
return backupItemDtoSchema.parse({
id: row.id, serverId: row.serverId, serverName: row.serverName, filename: row.filename,
sizeBytes: row.sizeBytes, createdAt: new Date(row.createdAt).toISOString(), kind: row.kind,
notes: row.notes ?? undefined, storage: row.storage, s3Key: row.s3Key, uploadError: row.uploadError,
})
}
+17 -42
View File
@@ -1,12 +1,14 @@
import { randomUUID } from "node:crypto"
import { mkdir, rm, stat, writeFile, readFile } from "node:fs/promises"
import path from "node:path"
import { desc, eq } from "drizzle-orm"
import { and, desc, eq } from "drizzle-orm"
import type {
BackupScheduleSettingsDto,
BackupStorageSettingsDto,
PutBackupStorageSettings,
} from "@mmapp/contracts/backups"
import { mapBackupEntry } from "./backup-dto.js"
import { planBackupStorageUpdate } from "./backup-storage-update.js"
import { db } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js"
@@ -45,21 +47,7 @@ export type BackupMeta = {
type BackupRow = typeof backupEntries.$inferSelect
function rowToMeta(row: BackupRow): BackupMeta {
return {
id: row.id,
serverId: row.serverId,
serverName: row.serverName,
filename: row.filename,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt,
kind: row.kind,
notes: row.notes ?? undefined,
storage: row.storage ?? "local",
s3Key: row.s3Key,
uploadError: row.uploadError,
}
}
function rowToMeta(row: BackupRow): BackupMeta { return mapBackupEntry(row) }
export async function ensureBackupStorage(): Promise<void> {
await mkdir(BACKUPS_DIR, { recursive: true })
@@ -260,19 +248,7 @@ export async function updateBackupStorageSettings(
): Promise<BackupStorageSettingsDto> {
const prev = await getBackupStorageSettingsRow()
const now = new Date().toISOString()
const secret = patch.s3SecretAccessKey
const next = {
provider: patch.provider ?? prev.provider,
s3Endpoint: patch.s3Endpoint ?? prev.s3Endpoint,
s3Region: patch.s3Region ?? prev.s3Region,
s3Bucket: patch.s3Bucket ?? prev.s3Bucket,
s3Prefix: patch.s3Prefix ?? prev.s3Prefix,
s3AccessKeyId: patch.s3AccessKeyId ?? prev.s3AccessKeyId,
s3SecretAccessKey: secret && secret.length > 0 ? secret : prev.s3SecretAccessKey,
s3ForcePathStyle: patch.s3ForcePathStyle ?? prev.s3ForcePathStyle,
keepLocalCopy: patch.keepLocalCopy ?? prev.keepLocalCopy,
updatedAt: now,
}
const next = planBackupStorageUpdate(prev, patch, now)
if ((await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]) {
await db.update(backupStorageSettings).set(next).where(eq(backupStorageSettings.id, SETTINGS_ID))
} else {
@@ -307,26 +283,25 @@ export async function testBackupStorageConnection(): Promise<BackupStorageSettin
const error = row.provider === "s3"
? "Заполните bucket, ключ доступа и секрет"
: "S3 не выбран"
await persistStorageTest(now, error)
await persistStorageTest(now, error, row.updatedAt)
throw new Error(error)
}
try {
const client = createS3ClientFromConfig(cfg)
await s3TestConnection(client, cfg.bucket)
await persistStorageTest(now, null)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
await persistStorageTest(now, message)
throw new Error(message)
}
return await getBackupStorageSettings()
let error: string | null = null
try { await s3TestConnection(createS3ClientFromConfig(cfg), cfg.bucket) }
catch (reason) { error = reason instanceof Error ? reason.message : String(reason) }
await persistStorageTest(now, error, row.updatedAt)
if (error) throw new Error(error)
const confirmed = await getBackupStorageSettings()
if (!confirmed.updatedAt || Date.parse(confirmed.updatedAt) !== Date.parse(now)) throw new Error("Настройки изменились во время проверки S3; повторите проверку")
return confirmed
}
async function persistStorageTest(at: string, error: string | null) {
async function persistStorageTest(at: string, error: string | null, expectedUpdatedAt: string) {
const exists = (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]
const patch = { lastTestAt: at, lastTestError: error, updatedAt: at }
if (exists) {
await db.update(backupStorageSettings).set(patch).where(eq(backupStorageSettings.id, SETTINGS_ID))
const rows = await db.update(backupStorageSettings).set(patch).where(and(eq(backupStorageSettings.id, SETTINGS_ID), eq(backupStorageSettings.updatedAt, expectedUpdatedAt))).returning({ id: backupStorageSettings.id })
if (!rows.length) throw new Error("Настройки изменились во время проверки S3; повторите проверку")
} else {
await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...patch })
}
@@ -0,0 +1,20 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { planBackupStorageUpdate } from "./backup-storage-update.js"
const row = { provider: "s3" as const, s3Endpoint: "https://s3.example.test", s3Region: "us-east-1", s3Bucket: "bucket", s3Prefix: "prefix", s3AccessKeyId: "test-key", s3SecretAccessKey: "test-secret", s3ForcePathStyle: false, keepLocalCopy: true, lastTestAt: "2026-10-01T00:00:00Z", lastTestError: null, updatedAt: "2026-10-01T00:00:00Z" }
const now = "2026-10-01T00:01:00Z"
test("unchanged credentials and blank key preserve secret and last receipt", () => {
const result = planBackupStorageUpdate(row, { s3SecretAccessKey: "", s3Bucket: row.s3Bucket }, now)
assert.equal(result.s3SecretAccessKey, row.s3SecretAccessKey); assert.equal(result.lastTestAt, row.lastTestAt)
assert.equal(result.updatedAt, now)
})
test("every effective S3 configuration change invalidates previous test metadata", () => {
for (const patch of [{ provider: "local" as const }, { s3Endpoint: "https://other.example.test" }, { s3Region: "new" }, { s3Bucket: "new" }, { s3Prefix: "new" }, { s3AccessKeyId: "new" }, { s3SecretAccessKey: "new-test-secret" }, { s3ForcePathStyle: true }]) {
const result = planBackupStorageUpdate({ ...row, lastTestError: "old failure" }, patch, now)
assert.equal(result.lastTestAt, null); assert.equal(result.lastTestError, null)
}
})
test("local retention does not invent a connection change or discard a failed test", () => {
const result = planBackupStorageUpdate({ ...row, lastTestError: "failed" }, { keepLocalCopy: false }, now)
assert.equal(result.keepLocalCopy, false); assert.equal(result.lastTestAt, row.lastTestAt); assert.equal(result.lastTestError, "failed")
})
@@ -0,0 +1,15 @@
import type { BackupStorageSettingsDto, PutBackupStorageSettings } from "@mmapp/contracts/backups"
type StorageRecord = Omit<BackupStorageSettingsDto, "secretConfigured"> & { s3SecretAccessKey: string }
/** A test of previous credentials cannot confirm the new configuration. */
export function planBackupStorageUpdate(prev: StorageRecord, patch: PutBackupStorageSettings, updatedAt: string) {
const next = {
provider: patch.provider ?? prev.provider, s3Endpoint: patch.s3Endpoint ?? prev.s3Endpoint, s3Region: patch.s3Region ?? prev.s3Region,
s3Bucket: patch.s3Bucket ?? prev.s3Bucket, s3Prefix: patch.s3Prefix ?? prev.s3Prefix, s3AccessKeyId: patch.s3AccessKeyId ?? prev.s3AccessKeyId,
s3SecretAccessKey: patch.s3SecretAccessKey && patch.s3SecretAccessKey.length > 0 ? patch.s3SecretAccessKey : prev.s3SecretAccessKey,
s3ForcePathStyle: patch.s3ForcePathStyle ?? prev.s3ForcePathStyle, keepLocalCopy: patch.keepLocalCopy ?? prev.keepLocalCopy,
lastTestAt: prev.lastTestAt ?? null, lastTestError: prev.lastTestError ?? null, updatedAt,
}
const connectionFields = ["provider", "s3Endpoint", "s3Region", "s3Bucket", "s3Prefix", "s3AccessKeyId", "s3SecretAccessKey", "s3ForcePathStyle"] as const
if (connectionFields.some(key => next[key] !== prev[key])) { next.lastTestAt = null; next.lastTestError = null }
return next
}