fix(deploy): verify desktop migration and bound private image sync

This commit is contained in:
WuYongJie 2026-10-20 13:53:22 +08:00
parent 971e17fcd8
commit 70369dbdb2
2 changed files with 32 additions and 2 deletions

View File

@ -1254,7 +1254,7 @@ verify_detail_generator_schema() {
local name="$1" local name="$1"
docker exec -i "$name" node <<'NODE' docker exec -i "$name" node <<'NODE'
const fs = require('fs'), crypto = require('crypto'), { PrismaClient } = require('@prisma/client'); const fs = require('fs'), crypto = require('crypto'), { PrismaClient } = require('@prisma/client');
const migrations = ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications']; const migrations = ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications', '20261020090000_desktop_client_workspace'];
const required = { detail_generator_projects: ['id','owner_id','document','revision','deleted_at'], detail_generator_assets: ['id','project_id','owner_id','storage_key','byte_size'], detail_generator_jobs: ['id','project_id','owner_id','status','idempotency_key','fingerprint','project_revision','payload','checkpoint','lease_token','lease_expires_at'], detail_generator_brand_profiles: ['id','name','profile','revision'] }; const required = { detail_generator_projects: ['id','owner_id','document','revision','deleted_at'], detail_generator_assets: ['id','project_id','owner_id','storage_key','byte_size'], detail_generator_jobs: ['id','project_id','owner_id','status','idempotency_key','fingerprint','project_revision','payload','checkpoint','lease_token','lease_expires_at'], detail_generator_brand_profiles: ['id','name','profile','revision'] };
const modelFields = { const modelFields = {
ai_model_providers: { id:'text', name:'text', base_url:'text', enabled:'boolean', credential_source:'text', secret_ciphertext:'text?', secret_nonce:'text?', secret_auth_tag:'text?', legacy_binding:'boolean', revision:'integer', synced_at:'timestamp with time zone?', created_at:'timestamp with time zone', updated_at:'timestamp with time zone' }, ai_model_providers: { id:'text', name:'text', base_url:'text', enabled:'boolean', credential_source:'text', secret_ciphertext:'text?', secret_nonce:'text?', secret_auth_tag:'text?', legacy_binding:'boolean', revision:'integer', synced_at:'timestamp with time zone?', created_at:'timestamp with time zone', updated_at:'timestamp with time zone' },
@ -1646,6 +1646,7 @@ const expectedMigrationNames = new Set([
'20261012_ai_model_registry', '20261012_ai_model_registry',
'20261013_ai_model_capabilities', '20261013_ai_model_capabilities',
'20261013_ai_model_verifications', '20261013_ai_model_verifications',
'20261020090000_desktop_client_workspace',
]); ]);
if (migrations.length !== expectedMigrationNames.size if (migrations.length !== expectedMigrationNames.size
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name) || migrations.some((item) => !expectedMigrationNames.has(item && item.name)
@ -2229,6 +2230,23 @@ http {
proxy_buffering off; proxy_buffering off;
} }
location ~ "^/api/desktop/projects/desktop/[A-Za-z0-9_-]{1,80}/assets/[^/]+\$" {
client_max_body_size 21m;
proxy_request_buffering off;
proxy_pass http://app_backend;
proxy_hide_header X-Material-Proxy-Target;
add_header X-Material-Proxy-Target ${target_container} always;
proxy_http_version 1.1;
proxy_set_header Host \$http_host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$forwarded_proto;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_connect_timeout 10s;
proxy_buffering off;
}
location ~ "^/api/detail-generator/projects/[0-9a-fA-F-]{36}/assets\$" { location ~ "^/api/detail-generator/projects/[0-9a-fA-F-]{36}/assets\$" {
client_max_body_size 17m; client_max_body_size 17m;
proxy_request_buffering off; proxy_request_buffering off;

View File

@ -83,6 +83,7 @@ test('migration deployment gate recognizes the latest approved schema migration'
assert.match(gate, /20261012_ai_model_registry/) assert.match(gate, /20261012_ai_model_registry/)
assert.match(gate, /20261013_ai_model_capabilities/) assert.match(gate, /20261013_ai_model_capabilities/)
assert.match(gate, /20261013_ai_model_verifications/) assert.match(gate, /20261013_ai_model_verifications/)
assert.match(gate, /20261020090000_desktop_client_workspace/)
}) })
test('direct replacement runs database and dependency gates before switching traffic', () => { test('direct replacement runs database and dependency gates before switching traffic', () => {
@ -291,7 +292,7 @@ test('AI model cutover gate checks every migration and rejects missing, mistyped
} }
const accepted = await run() const accepted = await run()
assert.equal(accepted.exitCode, 0) assert.equal(accepted.exitCode, 0)
assert.deepEqual(accepted.checked, ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications']) assert.deepEqual(accepted.checked, ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications', '20261020090000_desktop_client_workspace'])
for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications']) assert.equal((await run(columns, migration)).exitCode, 1) for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities', '20261013_ai_model_verifications']) assert.equal((await run(columns, migration)).exitCode, 1)
assert.equal((await run(columns.filter(row => row.column_name !== 'capability_metadata'))).exitCode, 1) assert.equal((await run(columns.filter(row => row.column_name !== 'capability_metadata'))).exitCode, 1)
assert.equal((await run(columns.map(row => row.column_name === 'capability_metadata' ? { ...row, data_type: 'text' } : row))).exitCode, 1) assert.equal((await run(columns.map(row => row.column_name === 'capability_metadata' ? { ...row, data_type: 'text' } : row))).exitCode, 1)
@ -316,6 +317,17 @@ test('detail uploads keep multipart headroom without expanding all proxy routes'
assert.match(deploySource(), /location ~ "\^\/api\/detail-generator\/projects\/\[0-9a-fA-F-\]\{36\}\/assets\\\$" \{\n\s+client_max_body_size 17m;\n\s+proxy_request_buffering off;/) assert.match(deploySource(), /location ~ "\^\/api\/detail-generator\/projects\/\[0-9a-fA-F-\]\{36\}\/assets\\\$" \{\n\s+client_max_body_size 17m;\n\s+proxy_request_buffering off;/)
}) })
test('desktop private image sync supports the route 20MiB limit without widening other endpoints', () => {
const source=deploySource()
const marker='location ~ "^/api/desktop/projects/desktop/[A-Za-z0-9_-]{1,80}/assets/[^/]+\\$" {'
const start=source.indexOf(marker)
assert.ok(start>=0)
const location=source.slice(start,source.indexOf('\n }',start))
assert.match(location,/client_max_body_size 21m;/)
assert.match(location,/proxy_request_buffering off;/)
assert.match(location,/proxy_set_header Host \\\$http_host;/)
})
test('brand knowledge upload stays bounded in the route and stable proxy', () => { test('brand knowledge upload stays bounded in the route and stable proxy', () => {
const deploySource = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8') const deploySource = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8')
.replace(/\r\n/g, '\n') .replace(/\r\n/g, '\n')