fix(backups): защитить подключение S3 и согласовать операции каталога
This commit is contained in:
1 parent
7cef0cc133
commit
faf452d149
18 files changed
+1385
-14
No files matched your search
@@ -0,0 +1,6 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs';import{createRequire}from'node:module';
|
||||
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');const baseline=process.env.MMAPP_ORIGIN_BASELINE==='true',report={date:'2026-10-14',block:'12.5.233',baseline,checks:[],externalS3Contacts:0,chrContacts:0,passed:false};const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const config={provider:'s3',s3Endpoint:'https://s3.invalid',s3Region:'test-region',s3Bucket:'origin-bucket',s3Prefix:'owned-test',s3AccessKeyId:'fixture',s3SecretAccessKey:'fixture',s3ForcePathStyle:false,keepLocalCopy:true};async function api(method,path,payload,status=200){const r=await app.inject({method,url:'/api'+path,...(payload?{payload}:{})});assert.equal(r.statusCode,status,method+' '+path);return r}
|
||||
async function insert(){await pool.query("INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES('owned-origin',NULL,'fixture','fixture.rsc',1,'manual','s3','owned-test/fixture.rsc',now())")}
|
||||
try{await api('PUT','/backups/storage',config);await insert();for(const patch of[{s3Bucket:'other-bucket'},{s3Endpoint:'https://other.invalid'},{s3Region:'other'},{s3ForcePathStyle:true},{provider:'local'}]){const before=(await api('GET','/backups/storage')).json();await api('PUT','/backups/storage',patch,baseline?200:409);const after=(await api('GET','/backups/storage')).json();if(!baseline)assert.deepEqual(after,before);else await api('PUT','/backups/storage',config);report.checks.push({change:Object.keys(patch)[0],status:baseline?200:409,settingsUnchanged:!baseline})}for(const patch of[{s3Prefix:'new-prefix'},{s3SecretAccessKey:'rotated-fixture'},{keepLocalCopy:false}]){await api('PUT','/backups/storage',patch);report.checks.push({allowed:Object.keys(patch)[0],status:200})}await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'");await api('PUT','/backups/storage',{s3Bucket:'empty-catalog-bucket'});report.checks.push({emptyCatalogChangeAllowed:true});if(!baseline){const{withBackupStorageOperation}=await import('/app/backend/dist/services/backup-storage-operation.js');let release,started;const active=new Promise(r=>started=r),wait=new Promise(r=>release=r);const creator=withBackupStorageOperation(async()=>{started();await wait;await insert()});await active;let settled=false;const update=api('PUT','/backups/storage',{s3Bucket:'racing-bucket'},409).then(()=>settled=true);await new Promise(r=>setTimeout(r,20));await update;assert.equal(settled,true);release();await creator;assert.equal((await api('GET','/backups/storage')).json().s3Bucket,'empty-catalog-bucket');report.checks.push({inFlightUpdateRejectedBeforeCatalogPublication:true});const{readBackupContent,getBackupById}=await import('/app/backend/dist/services/backup-service.js');const stale=await getBackupById('owned-origin');let releaseDelete;const deletion=withBackupStorageOperation(async()=>{await new Promise(r=>releaseDelete=r);await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'")});const download=readBackupContent(stale);const rejected=assert.rejects(download,/Бэкап не найден/);await Promise.resolve();releaseDelete();await deletion;await rejected;report.checks.push({staleDownloadRejectsBeforeS3AfterDeletion:true})}report.passed=true}catch(e){report.failure={name:e.name}}finally{await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'");await databaseBackgroundWork.drain();await app.close();await closePool();fs.writeFileSync('/checks/storage-origin233.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
cd /opt/mmapp-test
|
||||
mm233_db="mm_accept_166_$(date +%s%3N)"
|
||||
mm233_pass="$(openssl rand -hex 24)"
|
||||
mm233_role_created=false
|
||||
mm233_db_created=false
|
||||
cleanup233() {
|
||||
docker rm mmapp-s3app233-owned >/dev/null 2>&1 || true
|
||||
if $mm233_db_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP DATABASE \"$mm233_db\" WITH (FORCE)"; fi
|
||||
if $mm233_role_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP ROLE \"$mm233_db\""; fi
|
||||
remaining=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm233_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm233_db')")
|
||||
[ "$remaining" = 0 ]
|
||||
python3 - <<'PY'
|
||||
import json,pathlib
|
||||
p=pathlib.Path('/opt/mmapp-test/artifacts/backup233-checks/storage-origin233.json')
|
||||
if p.exists():
|
||||
r=json.loads(p.read_text());r['ownedDatabaseAndRoleRemoved']=True;p.write_text(json.dumps(r,indent=2)+'\n')
|
||||
PY
|
||||
printf 'OWNED_DATABASE_AND_ROLE_REMOVED\n'
|
||||
unset mm233_pass
|
||||
}
|
||||
trap cleanup233 EXIT
|
||||
[[ "$mm233_db" =~ ^mm_accept_166_[0-9]+$ ]]
|
||||
existing=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm233_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm233_db')")
|
||||
[ "$existing" = 0 ]
|
||||
printf "CREATE ROLE \"%s\" LOGIN PASSWORD '%s' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;\n" "$mm233_db" "$mm233_pass" | docker exec -i mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1
|
||||
mm233_role_created=true
|
||||
docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$mm233_db\" OWNER \"$mm233_db\""
|
||||
mm233_db_created=true
|
||||
docker run --name mmapp-s3app233-owned --network mmapp-test_default --cap-drop ALL --security-opt no-new-privileges -e "MMAPP_ORIGIN_BASELINE=${2:-false}" -e NODE_ENV=test -e "DATABASE_URL=postgres://$mm233_db:$mm233_pass@postgres:5432/$mm233_db" -e "MMAPP_OWN_SQL_DB=$mm233_db" -e "DATABASE_PATH=/tmp/$mm233_db/legacy.db" -e "GEOIP_DIR=/tmp/$mm233_db/geoip" -v /opt/mmapp-test/artifacts/backup233-checks:/checks --entrypoint node "${1:-mmapp-test/backend:ux-backup233}" /checks/backup-storage-origin.mjs
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "../services/backup-service.js"
|
||||
|
||||
import { BackupRestoreUnknownError } from "../services/backup-restore.js"
|
||||
import { BackupStorageConflictError } from "../services/backup-storage-operation.js"
|
||||
import { processBackupJob, recordBackupEvent } from "../services/backup-job-execution.js"
|
||||
|
||||
type BackupJob = BackupJobStatusResponse
|
||||
@@ -69,7 +70,12 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
return reply.send(await updateBackupStorageSettings(parsed.data))
|
||||
try {
|
||||
return reply.send(await updateBackupStorageSettings(parsed.data))
|
||||
} catch (error) {
|
||||
if (error instanceof BackupStorageConflictError) return reply.status(409).send({ error: error.message })
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/backups/storage/test", async (_req, reply) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdir, rm, stat, writeFile, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { and, desc, eq, isNotNull } from "drizzle-orm"
|
||||
import type {
|
||||
BackupScheduleSettingsDto,
|
||||
BackupStorageSettingsDto,
|
||||
@@ -11,7 +11,8 @@ import { mapBackupEntry } from "./backup-dto.js"
|
||||
import { pruneBackupRecords } from "./backup-prune.js"
|
||||
import { importBackupOnce } from "./backup-restore.js"
|
||||
import { deleteStoredBackup } from "./backup-deletion.js"
|
||||
import { planBackupStorageUpdate } from "./backup-storage-update.js"
|
||||
import { assertBackupStorageConnectionChange, planBackupStorageUpdate } from "./backup-storage-update.js"
|
||||
import { withBackupStorageOperation, withBackupStorageSettingsUpdate } from "./backup-storage-operation.js"
|
||||
import { db } from "../db/index.js"
|
||||
import { parseJsonArray } from "../db/json.js"
|
||||
import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js"
|
||||
@@ -67,6 +68,10 @@ export async function getBackupById(id: string): Promise<BackupMeta | null> {
|
||||
}
|
||||
|
||||
export async function insertBackup(meta: BackupMeta): Promise<void> {
|
||||
return withBackupStorageOperation(() => insertBackupRecord(meta))
|
||||
}
|
||||
|
||||
async function insertBackupRecord(meta: BackupMeta): Promise<void> {
|
||||
await db.insert(backupEntries).values({
|
||||
id: meta.id,
|
||||
serverId: meta.serverId,
|
||||
@@ -88,6 +93,10 @@ async function getBackupRow(id: string): Promise<BackupRow | undefined> {
|
||||
}
|
||||
|
||||
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
|
||||
return withBackupStorageOperation(() => deleteStoredBackupRecord(id))
|
||||
}
|
||||
|
||||
async function deleteStoredBackupRecord(id: string): Promise<BackupMeta | null> {
|
||||
const row = await getBackupRow(id)
|
||||
if (!row) return null
|
||||
const meta = rowToMeta(row)
|
||||
@@ -245,9 +254,15 @@ export async function getBackupStorageSettings(): Promise<BackupStorageSettingsD
|
||||
export async function updateBackupStorageSettings(
|
||||
patch: PutBackupStorageSettings,
|
||||
): Promise<BackupStorageSettingsDto> {
|
||||
return withBackupStorageSettingsUpdate(() => updateStorageSettings(patch))
|
||||
}
|
||||
|
||||
async function updateStorageSettings(patch: PutBackupStorageSettings): Promise<BackupStorageSettingsDto> {
|
||||
const prev = await getBackupStorageSettingsRow()
|
||||
const now = new Date().toISOString()
|
||||
const next = planBackupStorageUpdate(prev, patch, now)
|
||||
const remote = await db.select({ id: backupEntries.id }).from(backupEntries).where(isNotNull(backupEntries.s3Key)).limit(1)
|
||||
assertBackupStorageConnectionChange(prev, next, remote.length > 0)
|
||||
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 {
|
||||
@@ -375,6 +390,10 @@ export async function runBackupForServer(
|
||||
kind: BackupMeta["kind"],
|
||||
notes?: string,
|
||||
): Promise<BackupMeta> {
|
||||
return withBackupStorageOperation(() => createBackupForServer(id, kind, notes))
|
||||
}
|
||||
|
||||
async function createBackupForServer(id: string, kind: BackupMeta["kind"], notes?: string): Promise<BackupMeta> {
|
||||
const serverIdNum = Number.parseInt(id, 10)
|
||||
if (!Number.isFinite(serverIdNum)) {
|
||||
throw new Error("Невалидный id сервера")
|
||||
@@ -453,6 +472,15 @@ export async function pruneBackupsForServer(serverId: string, keepCount: number)
|
||||
}
|
||||
|
||||
export async function readBackupContent(meta: BackupMeta): Promise<Buffer> {
|
||||
return withBackupStorageOperation(async () => {
|
||||
// The route may have read metadata before a concurrent deletion changed the connection.
|
||||
const current = await getBackupById(meta.id)
|
||||
if (!current) throw new Error("Бэкап не найден")
|
||||
return readStoredBackupContent(current)
|
||||
})
|
||||
}
|
||||
|
||||
async function readStoredBackupContent(meta: BackupMeta): Promise<Buffer> {
|
||||
if ((meta.storage === "s3" || meta.storage === "both") && meta.s3Key) {
|
||||
try {
|
||||
const cfg = await getS3ConfigIfEnabled()
|
||||
@@ -481,6 +509,10 @@ export async function restoreBackupToDevice(id: string): Promise<{ backupId: str
|
||||
}
|
||||
|
||||
export async function syncBackupsFromS3(): Promise<{ imported: number; skipped: number }> {
|
||||
return withBackupStorageOperation(syncStoredBackupsFromS3)
|
||||
}
|
||||
|
||||
async function syncStoredBackupsFromS3(): Promise<{ imported: number; skipped: number }> {
|
||||
const cfg = await getS3ConfigIfEnabled()
|
||||
if (!cfg) throw new Error("S3 не настроен")
|
||||
const client = createS3ClientFromConfig(cfg)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Serializes storage changes with catalog-producing operations in one backend process. */
|
||||
let tail: Promise<unknown> = Promise.resolve()
|
||||
let pending = 0
|
||||
export function withBackupStorageOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
pending += 1
|
||||
const result = tail.then(operation).finally(() => { pending -= 1 })
|
||||
tail = result.catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
export async function withBackupStorageSettingsUpdate<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (pending > 0) throw new BackupStorageConflictError("Хранилище занято созданием или синхронизацией резервных копий. Дождитесь завершения операции и повторите сохранение.")
|
||||
return withBackupStorageOperation(operation)
|
||||
}
|
||||
|
||||
export class BackupStorageConflictError extends Error {
|
||||
constructor(message = "Смена подключения S3 недоступна: в каталоге есть резервные копии из текущего хранилища. Сохраните исходное подключение для скачивания и удаления этих файлов.") {
|
||||
super(message)
|
||||
this.name = "BackupStorageConflictError"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { planBackupStorageUpdate } from "./backup-storage-update.js"
|
||||
import { assertBackupStorageConnectionChange, planBackupStorageUpdate } from "./backup-storage-update.js"
|
||||
import { BackupStorageConflictError, withBackupStorageOperation, withBackupStorageSettingsUpdate } from "./backup-storage-operation.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", () => {
|
||||
@@ -18,3 +19,35 @@ test("local retention does not invent a connection change or discard a failed te
|
||||
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")
|
||||
})
|
||||
|
||||
test("remote catalog prevents addressing existing keys through another S3 connection", () => {
|
||||
for (const patch of [{ provider: "local" as const }, { s3Endpoint: "https://other.example.test" }, { s3Region: "new" }, { s3Bucket: "other" }, { s3ForcePathStyle: true }]) {
|
||||
const next = planBackupStorageUpdate(row, patch, now)
|
||||
assert.throws(() => assertBackupStorageConnectionChange(row, next, true), BackupStorageConflictError)
|
||||
assert.doesNotThrow(() => assertBackupStorageConnectionChange(row, next, false))
|
||||
}
|
||||
for (const patch of [{ s3Prefix: "new" }, { s3AccessKeyId: "rotated" }, { s3SecretAccessKey: "rotated" }, { keepLocalCopy: false }]) {
|
||||
assert.doesNotThrow(() => assertBackupStorageConnectionChange(row, planBackupStorageUpdate(row, patch, now), true))
|
||||
}
|
||||
assert.doesNotThrow(() => assertBackupStorageConnectionChange({ ...row, provider: "local" }, row, true))
|
||||
assert.throws(() => assertBackupStorageConnectionChange({ ...row, provider: "local" }, { ...row, s3Bucket: "other" }, true), BackupStorageConflictError)
|
||||
})
|
||||
|
||||
test("storage update rejects before mutation during upload, then sees the published catalog", async () => {
|
||||
let release!: () => void
|
||||
const uploaded = new Promise<void>(resolve => { release = resolve })
|
||||
let hasRemoteEntries = false, updateStarted = false
|
||||
const create = withBackupStorageOperation(async () => { await uploaded; hasRemoteEntries = true })
|
||||
const update = withBackupStorageSettingsUpdate(async () => {
|
||||
updateStarted = true
|
||||
assertBackupStorageConnectionChange(row, planBackupStorageUpdate(row, { s3Bucket: "other" }, now), hasRemoteEntries)
|
||||
})
|
||||
const rejected = assert.rejects(update, BackupStorageConflictError)
|
||||
await Promise.resolve(); assert.equal(updateStarted, false)
|
||||
release(); await create; await rejected
|
||||
assert.equal(updateStarted, false)
|
||||
await assert.rejects(withBackupStorageSettingsUpdate(async () => {
|
||||
assertBackupStorageConnectionChange(row, planBackupStorageUpdate(row, { s3Bucket: "other" }, now), hasRemoteEntries)
|
||||
}), BackupStorageConflictError)
|
||||
assert.equal(await withBackupStorageOperation(async () => "next operation"), "next operation")
|
||||
})
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { BackupStorageSettingsDto, PutBackupStorageSettings } from "@mmapp/contracts/backups"
|
||||
import { BackupStorageConflictError } from "./backup-storage-operation.js"
|
||||
type StorageRecord = Omit<BackupStorageSettingsDto, "secretConfigured"> & { s3SecretAccessKey: string }
|
||||
|
||||
export function assertBackupStorageConnectionChange(prev: StorageRecord, next: StorageRecord, hasRemoteEntries: boolean): void {
|
||||
const addressFields = ["s3Endpoint", "s3Region", "s3Bucket", "s3ForcePathStyle"] as const
|
||||
// Older installations may have disabled S3 while retaining its connection and catalog.
|
||||
if (hasRemoteEntries && (next.provider !== "s3" || addressFields.some(key => next[key] !== prev[key]))) throw new BackupStorageConflictError()
|
||||
}
|
||||
/** A test of previous credentials cannot confirm the new configuration. */
|
||||
export function planBackupStorageUpdate(prev: StorageRecord, patch: PutBackupStorageSettings, updatedAt: string) {
|
||||
const next = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# UX/UI: протокол внешней приёмки 12.5
|
||||
|
||||
Актуальное продолжение 2026-10-14:230–231 — исправлен нативный backup/export, S3 download и ограниченный restore собственного bridge приняты; /file/read поддерживает порции и8 МиБ,13 HTTP и оба CHR passed.232 — real UI save/test Selectel S3 HTTP200, vHosted/secret clearing/mobile receipt подтверждены. Исходные storage/schedule и все10 catalog rows восстановлены точно, backend ux-backup231 healthy/frontend ux-routing218. Далее233 — защита backup при смене bucket/endpoint; retention и расширенная приёмка открыты. Этап12.5 открыт.
|
||||
Актуальное продолжение 2026-10-14:230–232 — native S3 backup/ограниченный restore/порционное чтение и real UI save/test приняты, fixtures очищены.233 —5 небезопасных изменений S3 подключения отклоняются409 без записи; гонки create/sync/read/delete/settings согласованы в одном backend.11 owned SQL/API scenarios passed, старая231 принимала5 опасных changes200. Backend ux-backup233/frontend ux-routing218 healthy. Далее234 — запрет restore старых synthetic partial backups; version retention/multi-instance/расширенная приёмка открыты. Этап12.5 открыт.
|
||||
|
||||
IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md).
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# План реализации UX/UI по пятичасовым окнам
|
||||
|
||||
Актуальное продолжение 2026-10-14:230–231 — исправлен нативный backup/export, S3 download и ограниченный restore собственного bridge приняты; /file/read поддерживает порции и8 МиБ,13 HTTP и оба CHR passed.232 — real UI save/test Selectel S3 HTTP200, vHosted/secret clearing/mobile receipt подтверждены. Исходные storage/schedule и все10 catalog rows восстановлены точно, backend ux-backup231 healthy/frontend ux-routing218. Далее233 — защита backup при смене bucket/endpoint; retention и расширенная приёмка открыты. Этап12.5 открыт.
|
||||
Актуальное продолжение 2026-10-14:230–232 — native S3 backup/ограниченный restore/порционное чтение и real UI save/test приняты, fixtures очищены.233 —5 небезопасных изменений S3 подключения отклоняются409 без записи; гонки create/sync/read/delete/settings согласованы в одном backend.11 owned SQL/API scenarios passed, старая231 принимала5 опасных changes200. Backend ux-backup233/frontend ux-routing218 healthy. Далее234 — запрет restore старых synthetic partial backups; version retention/multi-instance/расширенная приёмка открыты. Этап12.5 открыт.
|
||||
|
||||
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-14. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
|
||||
|
||||
|
||||
+12
-2
@@ -1,6 +1,6 @@
|
||||
# UX/UI: журнал реализации
|
||||
|
||||
Актуальное продолжение 2026-10-14:230–231 — исправлен нативный backup/export, S3 download и ограниченный restore собственного bridge приняты; /file/read поддерживает порции и8 МиБ,13 HTTP и оба CHR passed.232 — real UI save/test Selectel S3 HTTP200, vHosted/secret clearing/mobile receipt подтверждены. Исходные storage/schedule и все10 catalog rows восстановлены точно, backend ux-backup231 healthy/frontend ux-routing218. Далее233 — защита backup при смене bucket/endpoint; retention и расширенная приёмка открыты. Этап12.5 открыт.
|
||||
Актуальное продолжение 2026-10-14:230–232 — native S3 backup/ограниченный restore/порционное чтение и real UI save/test приняты, fixtures очищены.233 —5 небезопасных изменений S3 подключения отклоняются409 без записи; гонки create/sync/read/delete/settings согласованы в одном backend.11 owned SQL/API scenarios passed, старая231 принимала5 опасных changes200. Backend ux-backup233/frontend ux-routing218 healthy. Далее234 — запрет restore старых synthetic partial backups; version retention/multi-instance/расширенная приёмка открыты. Этап12.5 открыт.
|
||||
|
||||
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
|
||||
|
||||
@@ -36,7 +36,17 @@
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка12.3.83 в границах журнала. Внешняя12.5: блоки1–232 завершены в записанных scopes, этап открыт. Runtime backend ux-backup231/frontend ux-routing218; backend25/UI52 и дополнительные SQL/UDP222 прошли. |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка12.3.83 в границах журнала. Внешняя12.5: блоки1–233 завершены в записанных scopes, этап открыт. Runtime backend ux-backup233/frontend ux-routing218; backend25/UI52 и дополнительные SQL/UDP222 прошли. |
|
||||
|
||||
## 12.5.233 — защита адресации S3 и гонок с каталогом (2026-10-14)
|
||||
|
||||
Backup row хранит s3Key, а bucket/endpoint берёт из global settings. Старая ux-backup231 в owned SQL приняла200 все5 изменений подключения при связанном remote catalog:bucket/endpoint/region/path-style/provider. Это подтверждение отсутствующего guard, без GET/DELETE чужих объектов; actual S3/CHR contacts0. [Baseline](ux-ui/acceptance/storage-origin233-baseline.json).
|
||||
|
||||
Теперь наличие s3Key блокирует такие изменения понятным409 до записи, сохраняя все settings fields. Prefix/keepLocalCopy/rotation keys разрешены; пустой remote catalog позволяет смену подключения. Legacy выключенный S3 можно включить с теми же адресными полями. Внутри одного backend create/import/sync/read/delete согласованы общей очередью; settings save при занятом хранилище немедленно409, без отложенной мутации после browser timeout. Чтение заново проверяет наличие catalog entry после получения gate, поэтому stale metadata после удаления не направляют GET на новое хранилище.
|
||||
|
||||
5 unit storage checks, test:backups (включая13 exporter cases), contracts/backend build и scoped ESLint passed. Compiled routes+отдельная ограниченная SQL:11 проверок —5 blocked changes с exact unchanged DTO,3 allowed changes,empty catalog,update during publication,stale download rejects before S3. Owned DB/role/container удалены с readback. [SQL/API11](ux-ui/acceptance/storage-origin233.json). Несколько промежуточных сборок уточняли recovery/busy/stale-read guards; повторные прогоны не суммируются в unique11.
|
||||
|
||||
Image ux-backup233:503 source files/1064 compiled outputs совпали с frozen snapshot; main backend healthy/image matched, frontend ux-routing218 сохранён. [Deploy](ux-ui/acceptance/backup233-deployment.json). Реальные S3 upload/restore и live UI232 повторно не выполнялись; production data/settings не менялись кроме image tag. Межпроцессная/межэкземплярная синхронизация и миграция существующих S3 файлов между buckets этим не реализованы. Privileged SQL writers обходят application guard; несколько backend остаются открытой группой. Далее234 — отклонить restore известного старого partial synthetic export до native upload/import. Этап12.5 открыт.
|
||||
|
||||
## 12.5.232 — сохранение Selectel S3 и проверка доступа в live UI (2026-10-14)
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Оставшаяся внешняя приёмка — 2026-10-14
|
||||
|
||||
Актуальное продолжение 2026-10-14:230–231 — исправлен нативный backup/export, S3 download и ограниченный restore собственного bridge приняты; /file/read поддерживает порции и8 МиБ,13 HTTP и оба CHR passed.232 — real UI save/test Selectel S3 HTTP200, vHosted/secret clearing/mobile receipt подтверждены. Исходные storage/schedule и все10 catalog rows восстановлены точно, backend ux-backup231 healthy/frontend ux-routing218. Далее233 — защита backup при смене bucket/endpoint; retention и расширенная приёмка открыты. Этап12.5 открыт.
|
||||
Актуальное продолжение 2026-10-14:230–232 — native S3 backup/ограниченный restore/порционное чтение и real UI save/test приняты, fixtures очищены.233 —5 небезопасных изменений S3 подключения отклоняются409 без записи; гонки create/sync/read/delete/settings согласованы в одном backend.11 owned SQL/API scenarios passed, старая231 принимала5 опасных changes200. Backend ux-backup233/frontend ux-routing218 healthy. Далее234 — запрет restore старых synthetic partial backups; version retention/multi-instance/расширенная приёмка открыты. Этап12.5 открыт.
|
||||
|
||||
Это актуальная матрица после 12.5.232. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
|
||||
Это актуальная матрица после 12.5.233. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
|
||||
|
||||
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-backup231`, frontend `ux-routing218`; production lab с195 использует AUTH_REQUIRED=true и Portal1.3.1. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
|
||||
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-backup233`, frontend `ux-routing218`; production lab с195 использует AUTH_REQUIRED=true и Portal1.3.1. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
|
||||
|
||||
## Остаток приёмки по группам — 2026-10-14
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| REST и достоверность данных | Ошибки OSPF/BFD/BGP/GRE/WAN215–221 явные; latest API smoke222 прошёл | Причина единичных native401/500 неизвестна. Короткое независимое наблюдение возможно сейчас; оно не даст SLA |
|
||||
| Клиентские ОС и внешняя сеть | CHR↔CHR/Linux, private IPv4/ULA и synthetic IPv6/IPFIX; Android full-tunnel/reconnect227 подтверждены пользователем | Windows/macOS/iOS VPN, расширенная Android приёмка, независимые WAN/multi-WAN и public IPv6; public IPv6 на текущем стенде отсутствует |
|
||||
| Масштаб и длительная устойчивость | Контролируемые lock/dedupe/loss/recovery и короткие native проверки | Несколько backend/worker/writers, длительный rekey/REST/traffic, physical disconnect и maximum throughput |
|
||||
| Внешние хранилища и jobs | Локальные API/errors/RBAC/owned SQL; реальные Portal1.3.1/Telegram195–201 и Selectel S3/client/API/ownSQL228–229 | Large native exports проверены HTTP fixture; S3 restore через UI, смена bucket/endpoint и retention; provider/container/Gitea/MaxMind jobs с соответствующими endpoints/аккаунтами; private/paid credentials не выдумывать |
|
||||
| Внешние хранилища и jobs | Локальные API/errors/RBAC/owned SQL; реальные Portal1.3.1/Telegram195–201 и Selectel S3/client/API/ownSQL228–229 | Large native exports проверены HTTP fixture; S3 restore через UI, миграция между buckets/multi-instance и retention; provider/container/Gitea/MaxMind jobs с соответствующими endpoints/аккаунтами; private/paid credentials не выдумывать |
|
||||
| Расширенные сетевые конфигурации | GRE/WG/VXLAN/IPsec/OSPF/BFD/BGP сценарии и точные границы записаны | from-pool/EUI-64/multiple-address import/export, outer IPv6, произвольная PKI/OID/policy/path-length и длительные переходы; BFD-only gate гарантией не стал |
|
||||
| Общий UX, доступность и производительность | Полная UI192 исторически; latest backend25/UI52, scoped contrast/live218 и keyboard206 | Свежая UI226 принята в записанных границах; профильный performance и ручная accessibility/screen-reader приёмка; scoped axe не полный WCAG |
|
||||
| Границы выпуска | План/доказательства/датированные блоки сохранены; коммиты локальные | Определить требуемые конфигурации и принять результат в этих границах; невыполненные варианты остаются явными ограничениями |
|
||||
|
||||
+12
-1
@@ -1,6 +1,6 @@
|
||||
# Этап 12: итоговая приёмка UX/UI
|
||||
|
||||
Актуальное продолжение 2026-10-14:230–231 — исправлен нативный backup/export, S3 download и ограниченный restore собственного bridge приняты; /file/read поддерживает порции и8 МиБ,13 HTTP и оба CHR passed.232 — real UI save/test Selectel S3 HTTP200, vHosted/secret clearing/mobile receipt подтверждены. Исходные storage/schedule и все10 catalog rows восстановлены точно, backend ux-backup231 healthy/frontend ux-routing218. Далее233 — защита backup при смене bucket/endpoint; retention и расширенная приёмка открыты. Этап12.5 открыт.
|
||||
Актуальное продолжение 2026-10-14:230–232 — native S3 backup/ограниченный restore/порционное чтение и real UI save/test приняты, fixtures очищены.233 —5 небезопасных изменений S3 подключения отклоняются409 без записи; гонки create/sync/read/delete/settings согласованы в одном backend.11 owned SQL/API scenarios passed, старая231 принимала5 опасных changes200. Backend ux-backup233/frontend ux-routing218 healthy. Далее234 — запрет restore старых synthetic partial backups; version retention/multi-instance/расширенная приёмка открыты. Этап12.5 открыт.
|
||||
|
||||
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
|
||||
|
||||
@@ -4120,3 +4120,14 @@ Real Portal login, ordinary /backups/API, без mocks. Через UI сохра
|
||||
|
||||
Cleanup в SQL transaction восстановил все исходные поля двух singleton records, включая secret/updatedAt/history; все10 catalog rows совпали точно. Backend перезапущен для загрузки исходного расписания и healthy на image ux-backup231. Private snapshot и local S3 credential JSON удалены. [Точная очистка](ux-ui/acceptance/storage232-cleanup.json). Секреты/скриншоты credential forms не опубликованы, пароли CHR не менялись. Frontend ux-routing218 сохранён. Следующий233 — проверить и защитить адресацию существующих backups при смене S3 bucket/endpoint. Version retention приложения и остальные группы остаются открытыми; этап12.5 открыт.
|
||||
|
||||
|
||||
## 12.5.233 — защита адресации S3 и гонок с каталогом (2026-10-14)
|
||||
|
||||
Backup row хранит s3Key, а bucket/endpoint берёт из global settings. Старая ux-backup231 в owned SQL приняла200 все5 изменений подключения при связанном remote catalog:bucket/endpoint/region/path-style/provider. Это подтверждение отсутствующего guard, без GET/DELETE чужих объектов; actual S3/CHR contacts0. [Baseline](ux-ui/acceptance/storage-origin233-baseline.json).
|
||||
|
||||
Теперь наличие s3Key блокирует такие изменения понятным409 до записи, сохраняя все settings fields. Prefix/keepLocalCopy/rotation keys разрешены; пустой remote catalog позволяет смену подключения. Legacy выключенный S3 можно включить с теми же адресными полями. Внутри одного backend create/import/sync/read/delete согласованы общей очередью; settings save при занятом хранилище немедленно409, без отложенной мутации после browser timeout. Чтение заново проверяет наличие catalog entry после получения gate, поэтому stale metadata после удаления не направляют GET на новое хранилище.
|
||||
|
||||
5 unit storage checks, test:backups (включая13 exporter cases), contracts/backend build и scoped ESLint passed. Compiled routes+отдельная ограниченная SQL:11 проверок —5 blocked changes с exact unchanged DTO,3 allowed changes,empty catalog,update during publication,stale download rejects before S3. Owned DB/role/container удалены с readback. [SQL/API11](ux-ui/acceptance/storage-origin233.json). Несколько промежуточных сборок уточняли recovery/busy/stale-read guards; повторные прогоны не суммируются в unique11.
|
||||
|
||||
Image ux-backup233:503 source files/1064 compiled outputs совпали с frozen snapshot; main backend healthy/image matched, frontend ux-routing218 сохранён. [Deploy](ux-ui/acceptance/backup233-deployment.json). Реальные S3 upload/restore и live UI232 повторно не выполнялись; production data/settings не менялись кроме image tag. Межпроцессная/межэкземплярная синхронизация и миграция существующих S3 файлов между buckets этим не реализованы. Privileged SQL writers обходят application guard; несколько backend остаются открытой группой. Далее234 — отклонить restore известного старого partial synthetic export до native upload/import. Этап12.5 открыт.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Тестовый сервер UX/UI — 2026-10-10
|
||||
|
||||
Актуальное продолжение 2026-10-14:230–231 — исправлен нативный backup/export, S3 download и ограниченный restore собственного bridge приняты; /file/read поддерживает порции и8 МиБ,13 HTTP и оба CHR passed.232 — real UI save/test Selectel S3 HTTP200, vHosted/secret clearing/mobile receipt подтверждены. Исходные storage/schedule и все10 catalog rows восстановлены точно, backend ux-backup231 healthy/frontend ux-routing218. Далее233 — защита backup при смене bucket/endpoint; retention и расширенная приёмка открыты. Этап12.5 открыт.
|
||||
Актуальное продолжение 2026-10-14:230–232 — native S3 backup/ограниченный restore/порционное чтение и real UI save/test приняты, fixtures очищены.233 —5 небезопасных изменений S3 подключения отклоняются409 без записи; гонки create/sync/read/delete/settings согласованы в одном backend.11 owned SQL/API scenarios passed, старая231 принимала5 опасных changes200. Backend ux-backup233/frontend ux-routing218 healthy. Далее234 — запрет restore старых synthetic partial backups; version retention/multi-instance/расширенная приёмка открыты. Этап12.5 открыт.
|
||||
|
||||
| Параметр | Проверенное состояние |
|
||||
| --- | --- |
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"date": "2026-10-14",
|
||||
"block": "12.5.233",
|
||||
"backendSourceMatched": 503,
|
||||
"compiledMatched": 1064,
|
||||
"frontendChanged": false,
|
||||
"services": {
|
||||
"backend": {
|
||||
"image": "mmapp-test/backend:ux-backup233",
|
||||
"imageId": "sha256:f7dbe781cc96b578cc3c81d37e3cbda2d5271fd2a885cd0861f588810b4328d4",
|
||||
"running": true,
|
||||
"health": "healthy"
|
||||
},
|
||||
"frontend": {
|
||||
"image": "mmapp-test/frontend:ux-routing218",
|
||||
"imageId": "sha256:ec658b9da88c19d60987be5bfc0c15fb339a0310d7a42dc761b40a70a3428c5a",
|
||||
"running": true,
|
||||
"health": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"date": "2026-10-14",
|
||||
"block": "12.5.233",
|
||||
"kinds": [
|
||||
{
|
||||
"kind": "backend",
|
||||
"matched": 503
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"date": "2026-10-14",
|
||||
"block": "12.5.233",
|
||||
"baseline": true,
|
||||
"checks": [
|
||||
{
|
||||
"change": "s3Bucket",
|
||||
"status": 200,
|
||||
"settingsUnchanged": false
|
||||
},
|
||||
{
|
||||
"change": "s3Endpoint",
|
||||
"status": 200,
|
||||
"settingsUnchanged": false
|
||||
},
|
||||
{
|
||||
"change": "s3Region",
|
||||
"status": 200,
|
||||
"settingsUnchanged": false
|
||||
},
|
||||
{
|
||||
"change": "s3ForcePathStyle",
|
||||
"status": 200,
|
||||
"settingsUnchanged": false
|
||||
},
|
||||
{
|
||||
"change": "provider",
|
||||
"status": 200,
|
||||
"settingsUnchanged": false
|
||||
},
|
||||
{
|
||||
"allowed": "s3Prefix",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"allowed": "s3SecretAccessKey",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"allowed": "keepLocalCopy",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"emptyCatalogChangeAllowed": true
|
||||
}
|
||||
],
|
||||
"externalS3Contacts": 0,
|
||||
"chrContacts": 0,
|
||||
"passed": true,
|
||||
"ownedDatabaseAndRoleRemoved": true
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"date": "2026-10-14",
|
||||
"block": "12.5.233",
|
||||
"baseline": false,
|
||||
"checks": [
|
||||
{
|
||||
"change": "s3Bucket",
|
||||
"status": 409,
|
||||
"settingsUnchanged": true
|
||||
},
|
||||
{
|
||||
"change": "s3Endpoint",
|
||||
"status": 409,
|
||||
"settingsUnchanged": true
|
||||
},
|
||||
{
|
||||
"change": "s3Region",
|
||||
"status": 409,
|
||||
"settingsUnchanged": true
|
||||
},
|
||||
{
|
||||
"change": "s3ForcePathStyle",
|
||||
"status": 409,
|
||||
"settingsUnchanged": true
|
||||
},
|
||||
{
|
||||
"change": "provider",
|
||||
"status": 409,
|
||||
"settingsUnchanged": true
|
||||
},
|
||||
{
|
||||
"allowed": "s3Prefix",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"allowed": "s3SecretAccessKey",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"allowed": "keepLocalCopy",
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"emptyCatalogChangeAllowed": true
|
||||
},
|
||||
{
|
||||
"inFlightUpdateRejectedBeforeCatalogPublication": true
|
||||
},
|
||||
{
|
||||
"staleDownloadRejectsBeforeS3AfterDeletion": true
|
||||
}
|
||||
],
|
||||
"externalS3Contacts": 0,
|
||||
"chrContacts": 0,
|
||||
"passed": true,
|
||||
"ownedDatabaseAndRoleRemoved": true
|
||||
}
|
||||
Reference in new issue
Block a user