Files
dsh_ai1net_server/scripts/ensure-anysearch-pool.mjs
T

126 lines
6.0 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* ensure-anysearch-pool.mjs —— 把 AnySearch 插件投放进「功能插件」候选池(档案 64 / 65)
*
* 与门户 `POST /api/plugins/business`(admin 上传)走**同一条校验路径**:复用平台编译产物
* 导出的 `stageTgzArchive()`(列成员防路径穿越 → 解压 → `package.json` 校验 → 危险内容扫描),
* 再按同一「替换策略」(同名先删旧 tgz、旧文件无残留)落盘进 `<dataRoot>/business-plugins/`,
* 并 upsert `business_plugins` 行。
*
* 投放完成后,用户在实例「设置 → 功能管理」里自助**启用/禁用**;启用/禁用会由
* `syncWebProviderPatch()`(档案 65)自动维护 profile 的 web provider 托管段。
*
* 为什么需要它:门户上传需要浏览器的 admin 会话;本脚本用于无会话场景(如本次迁移),
* 产物与走门户完全一致。
*
* 用法:
* node scripts/ensure-anysearch-pool.mjs # 干跑(只打印)
* node scripts/ensure-anysearch-pool.mjs --apply # 执行投放
*/
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { join } from 'node:path'
import Database from '/opt/dshs/node_modules/better-sqlite3/lib/index.js'
const DATA_ROOT = process.env.DSH_DATA_ROOT ?? '/var/lib/dshs'
const ART_DIR = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts'
const POOL_DIR = join(DATA_ROOT, 'business-plugins')
const DB_PATH = join(DATA_ROOT, 'dshs.db')
const PLUGIN_ROUTES = '/opt/dshs/lib/web/routes/business-plugins.js'
const PREFIX = 'anysearch-dsh-'
const APPLY = process.argv.includes('--apply')
/** 取产物目录里版本号最大的 anysearch-dsh-*.tgz。 */
function pickArtifact() {
const cands = readdirSync(ART_DIR).filter((f) => f.startsWith(PREFIX) && f.endsWith('.tgz'))
if (cands.length === 0) throw new Error(`no ${PREFIX}*.tgz under ${ART_DIR}`)
const ver = (f) => f.slice(PREFIX.length, -4).split('.').map(Number)
cands.sort((a, b) => {
const va = ver(a); const vb = ver(b)
for (let i = 0; i < 3; i++) { const x = va[i] || 0; const y = vb[i] || 0; if (x !== y) return x - y }
return 0
})
return join(ART_DIR, cands[cands.length - 1])
}
const artifact = pickArtifact()
const size = statSync(artifact).size
console.log(`artifact = ${artifact}`)
console.log(`size = ${size} B`)
console.log(`pool dir = ${POOL_DIR}`)
console.log(`mode = ${APPLY ? 'APPLY' : 'DRY-RUN'}`)
if (!APPLY) {
console.log('\n(干跑结束;加 --apply 执行)')
process.exit(0)
}
// 1) 走平台自己的校验 + staging(与门户上传完全一致)
const { stageTgzArchive } = await import(PLUGIN_ROUTES)
const staged = stageTgzArchive(DATA_ROOT, readFileSync(artifact))
console.log(`staged = name=${staged.name} version=${staged.version} tgz=${staged.tgzName} files=${staged.fileCount}`)
// 1b) 安全检测裁决 —— 与上传接口同一套语义:**默认 fail-closed**,只有显式声明信任才放行。
const TRUST = process.argv.includes('--trust')
if (staged.blocked.length > 0) {
console.log(`\n⚠ 安全检测命中 P0 规则 ${staged.blocked.length} 处:`)
for (const b of staged.blocked) console.log(` - ${b.file} — ${b.why}`)
if (!TRUST) {
rmSync(staged.stage, { recursive: true, force: true })
console.log('\n默认拒绝投放(fail-closed)。逐条确认确属误报 / 风险可控后,加 --trust 重新执行;')
console.log('放行会写入 audit_log(trust_business_plugin),留痕「谁 / 何时 / 命中什么 / 理由」。')
process.exit(2)
}
console.log(' (--trust 已声明 → 继续投放并留痕)')
} else {
console.log('scan = clean(无 P0 命中)')
}
if (staged.warnings.length > 0) console.log(`scan = ${staged.warnings.length} 条 P1 告警(不阻断)`)
// 2) 替换策略落盘
mkdirSync(POOL_DIR, { recursive: true, mode: 0o755 })
const db = new Database(DB_PATH)
const existing = db.prepare('SELECT id, tgz_path FROM business_plugins WHERE id = ?').get(staged.name)
if (existing !== undefined && existsSync(existing.tgz_path)) {
rmSync(existing.tgz_path, { force: true })
console.log(`replaced = 已删除旧包 ${existing.tgz_path}`)
}
const dest = join(POOL_DIR, staged.tgzName)
renameSync(join(staged.stage, 'payload.tgz'), dest)
// 3) upsert 元数据行
const now = Date.now()
const admin = db.prepare("SELECT id FROM users WHERE username = 'admin'").get()
const uploadedBy = admin?.id ?? null
if (existing !== undefined) {
db.prepare(
'UPDATE business_plugins SET name=?, description=?, version=?, tgz_path=?, file_size=?, uploaded_by=?, updated_at=? WHERE id=?',
).run(staged.name, staged.name, staged.description, staged.version, dest, size, uploadedBy, now, staged.name)
} else {
db.prepare(
'INSERT INTO business_plugins (id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)',
).run(staged.name, staged.name, staged.description, staged.version, dest, size, uploadedBy, now, now)
}
// 留痕:与上传接口同一组 action(命中 P0 时另记一条 trust_business_plugin)
db.prepare('INSERT INTO audit_log (ts, actor, action, detail) VALUES (?, ?, ?, ?)').run(
now,
uploadedBy,
'upload_business_plugin',
JSON.stringify({ name: staged.name, version: staged.version, trustedOverride: staged.blocked.length > 0, via: 'ensure-anysearch-pool.mjs' }),
)
if (staged.blocked.length > 0) {
db.prepare('INSERT INTO audit_log (ts, actor, action, detail) VALUES (?, ?, ?, ?)').run(
now,
uploadedBy,
'trust_business_plugin',
JSON.stringify({ name: staged.name, version: staged.version, blocked: staged.blocked, reason: (process.env.TRUST_REASON ?? '').trim() }),
)
}
const rows = db.prepare('SELECT id, version, file_size, tgz_path FROM business_plugins ORDER BY id ASC').all()
db.close()
rmSync(staged.stage, { recursive: true, force: true })
console.log(`\n✓ 已投放:${dest}`)
console.log('候选池当前内容:')
for (const r of rows) console.log(` - ${r.id} @ ${r.version} (${r.file_size} B) → ${r.tgz_path}`)