@aipt/idp-deploy 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +471 -0
  2. package/bin/idpctl.mjs +8 -0
  3. package/compose/edge.yaml +61 -0
  4. package/compose/flow.yaml +34 -0
  5. package/compose/portal.yaml +38 -0
  6. package/compose/postgresql.yaml +62 -0
  7. package/compose/registry.yaml +35 -0
  8. package/compose/smartgo.yaml +271 -0
  9. package/compose/tech.yaml +64 -0
  10. package/contracts/active-profile.schema.json +19 -0
  11. package/contracts/asset-lifecycle.schema.json +49 -0
  12. package/contracts/backup-generation.schema.json +60 -0
  13. package/contracts/component-runtime.schema.json +32 -0
  14. package/contracts/config-release.schema.json +33 -0
  15. package/contracts/deployment-evidence.schema.json +43 -0
  16. package/contracts/deployment-plan.schema.json +67 -0
  17. package/contracts/release-candidate.schema.json +33 -0
  18. package/contracts/release-defaults.schema.json +56 -0
  19. package/contracts/restore-candidate.schema.json +63 -0
  20. package/contracts/smartgo-component-config.schema.json +79 -0
  21. package/contracts/tech-backup-boundary.schema.json +61 -0
  22. package/contracts/tech-source-credentials.schema.json +17 -0
  23. package/deploy.sh +5 -0
  24. package/docs/restore-runbook.md +56 -0
  25. package/governance/asset-lifecycle.v1.json +79 -0
  26. package/package.json +42 -0
  27. package/release/defaults.v1.json +64 -0
  28. package/src/acceptance.mjs +127 -0
  29. package/src/bindings.mjs +518 -0
  30. package/src/cli.mjs +360 -0
  31. package/src/compose.mjs +19 -0
  32. package/src/config.mjs +903 -0
  33. package/src/delivery.mjs +123 -0
  34. package/src/errors.mjs +12 -0
  35. package/src/foundation-contracts.mjs +128 -0
  36. package/src/foundation.mjs +107 -0
  37. package/src/gitops.mjs +285 -0
  38. package/src/hash.mjs +47 -0
  39. package/src/image-lock.mjs +160 -0
  40. package/src/images.mjs +215 -0
  41. package/src/lifecycle.mjs +58 -0
  42. package/src/local-source.mjs +354 -0
  43. package/src/oci-mirror.mjs +10 -0
  44. package/src/operations.mjs +1484 -0
  45. package/src/process.mjs +46 -0
  46. package/src/profiles.mjs +41 -0
  47. package/src/release.mjs +1760 -0
  48. package/src/render.mjs +330 -0
  49. package/src/security.mjs +156 -0
  50. package/src/source-contracts.mjs +106 -0
  51. package/src/sources.mjs +78 -0
  52. package/src/workbench-projects.mjs +118 -0
@@ -0,0 +1,1484 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { composeArgs } from './compose.mjs';
6
+ import { doctorConfig } from './config.mjs';
7
+ import { IdpError, invariant } from './errors.mjs';
8
+ import { hashFile, inventoryDirectory, sha256, stableJson } from './hash.mjs';
9
+ import {
10
+ assertDisjointRoots,
11
+ assertOutsideRepositories,
12
+ assertSafeRegularFile,
13
+ atomicWrite,
14
+ createExclusiveFile,
15
+ resolveContained,
16
+ secureDirectoryRoot,
17
+ } from './security.mjs';
18
+ import { run, runFromFile, runToFile } from './process.mjs';
19
+ import { renderRuntimeBundles } from './render.mjs';
20
+ import { resolveProfile, resolveProfileServices } from './profiles.mjs';
21
+
22
+ const DATABASE_BY_COMPONENT = Object.freeze({ tech: 'tech', portal: 'backstage', smartgo: 'smartgo' });
23
+ const ROLE_BY_DATABASE = Object.freeze({ tech: 'tech', backstage: 'portal', smartgo: 'smartgo' });
24
+ const SMARTGO_WRITER_SERVICES = Object.freeze([
25
+ 'smartgo-api',
26
+ 'smartgo-agent-runtime',
27
+ 'smartgo-worker',
28
+ 'smartgo-gotology',
29
+ 'smartgo-operations',
30
+ 'smartgo-studio',
31
+ ]);
32
+ const SMARTGO_BACKUP_RECOVERY_POLICY = Object.freeze({
33
+ maxAttempts: 3,
34
+ retryDelayMilliseconds: 2_000,
35
+ objectStoreWaitTimeoutSeconds: 30,
36
+ profileWaitTimeoutSeconds: 60,
37
+ });
38
+
39
+ function profileDatasets(profile) {
40
+ const active = new Set(resolveProfile(profile));
41
+ const datasets = [];
42
+ for (const component of ['tech', 'flow', 'portal', 'smartgo']) {
43
+ if (active.has(component) && DATABASE_BY_COMPONENT[component]) datasets.push(`postgresql:${DATABASE_BY_COMPONENT[component]}`);
44
+ }
45
+ if (active.has('registry')) datasets.push('registry:packages');
46
+ if (active.has('smartgo')) datasets.push('smartgo-object-store:objects');
47
+ invariant(datasets.length > 0, 'IDP_PROFILE_HAS_NO_DATA', `${profile}没有可备份的数据集`);
48
+ return { active, datasets };
49
+ }
50
+
51
+ function ensureRuntimeRoots(root, env) {
52
+ const entries = [['IDP_CONFIG_DIR', root]];
53
+ for (const key of ['IDP_DATA_DIR', 'IDP_BACKUP_DIR', 'IDP_LOG_DIR', 'IDP_RUNTIME_DIR', 'IDP_RESTORE_DIR']) {
54
+ const canonical = secureDirectoryRoot(env[key], key, { create: true });
55
+ assertOutsideRepositories(canonical, key);
56
+ env[key] = canonical;
57
+ entries.push([key, canonical]);
58
+ }
59
+ assertDisjointRoots(entries);
60
+ for (const relative of ['locks', 'receipts', 'evidence', 'tmp', 'state']) secureDirectoryRoot(path.join(env.IDP_RUNTIME_DIR, relative), `Runtime ${relative}`, { create: true });
61
+ for (const relative of ['staging', 'generations', 'failed']) secureDirectoryRoot(path.join(env.IDP_BACKUP_DIR, relative), `Backup ${relative}`, { create: true });
62
+ for (const relative of ['candidates', 'failed']) secureDirectoryRoot(path.join(env.IDP_RESTORE_DIR, relative), `Restore ${relative}`, { create: true });
63
+ for (const relative of ['postgresql', 'registry', 'registry/auth', 'registry/packages', 'smartgo', 'smartgo/object-store', 'edge', 'edge/data', 'edge/config']) {
64
+ secureDirectoryRoot(path.join(env.IDP_DATA_DIR, relative), `Data ${relative}`, { create: true, allowForeignOwner: true });
65
+ }
66
+ }
67
+
68
+ function activeProfileStatePath(env) {
69
+ return path.join(env.IDP_RUNTIME_DIR, 'state', 'active-profile.json');
70
+ }
71
+
72
+ export function readActiveProfileState(env) {
73
+ const candidate = activeProfileStatePath(env);
74
+ if (!fs.existsSync(candidate)) return null;
75
+ assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: '活动Profile状态' });
76
+ let document;
77
+ try { document = JSON.parse(fs.readFileSync(candidate, 'utf8')); }
78
+ catch { throw new IdpError('IDP_PROFILE_STATE_INVALID', '活动Profile状态不是有效JSON'); }
79
+ const { digest, ...body } = document ?? {};
80
+ const exactKeys = ['schemaVersion', 'status', 'profile', 'services', 'configDigest', 'renderDigest', 'updatedAt', 'digest'];
81
+ invariant(
82
+ document && Object.keys(document).sort().join('\0') === exactKeys.sort().join('\0') &&
83
+ document.schemaVersion === 1 && ['active', 'stopped'].includes(document.status) &&
84
+ typeof document.profile === 'string' && Array.isArray(document.services) &&
85
+ document.services.every((value) => typeof value === 'string') && new Set(document.services).size === document.services.length &&
86
+ /^sha256:[0-9a-f]{64}$/u.test(document.configDigest ?? '') && /^sha256:[0-9a-f]{64}$/u.test(document.renderDigest ?? '') &&
87
+ /^sha256:[0-9a-f]{64}$/u.test(digest ?? '') && sha256(stableJson(body)) === digest,
88
+ 'IDP_PROFILE_STATE_INVALID', '活动Profile状态契约或摘要无效',
89
+ );
90
+ invariant(stableJson(document.services) === stableJson(resolveProfileServices(document.profile)), 'IDP_PROFILE_STATE_INVALID', '活动Profile服务集合与Profile不一致');
91
+ return document;
92
+ }
93
+
94
+ function writeActiveProfileState(env, { status, profile, services, configDigest, renderDigest }) {
95
+ const candidate = activeProfileStatePath(env);
96
+ if (fs.existsSync(candidate)) {
97
+ const before = fs.readFileSync(candidate);
98
+ const preimage = path.join(env.IDP_RUNTIME_DIR, 'evidence', `active-profile-${Date.now()}-${crypto.randomUUID()}.preimage.json`);
99
+ createExclusiveFile(preimage, before, 0o600);
100
+ }
101
+ const body = { schemaVersion: 1, status, profile, services, configDigest, renderDigest, updatedAt: new Date().toISOString() };
102
+ const document = { ...body, digest: sha256(stableJson(body)) };
103
+ atomicWrite(candidate, `${JSON.stringify(document, null, 2)}\n`, 0o600);
104
+ return document;
105
+ }
106
+
107
+ function composeProcessEnvironment(root, renderDirectory) {
108
+ const environment = { ...process.env };
109
+ for (const key of Object.keys(environment)) {
110
+ if (key.startsWith('IDP_')) delete environment[key];
111
+ }
112
+ environment.IDP_CONFIG_DIR = root;
113
+ environment.IDP_RENDER_DIR = renderDirectory;
114
+ return environment;
115
+ }
116
+
117
+ function processIsAlive(pid) {
118
+ if (!Number.isInteger(pid) || pid <= 0) return false;
119
+ try { process.kill(pid, 0); return true; }
120
+ catch (error) { return error?.code === 'EPERM'; }
121
+ }
122
+
123
+ function acquireInstanceLock(root, env, operation) {
124
+ ensureRuntimeRoots(root, env);
125
+ const lockPath = path.join(env.IDP_RUNTIME_DIR, 'locks', 'instance.lock');
126
+ if (fs.existsSync(lockPath)) {
127
+ assertSafeRegularFile(lockPath, 0o600, { allowEmpty: false, role: '实例操作锁' });
128
+ let current;
129
+ try { current = JSON.parse(fs.readFileSync(lockPath, 'utf8')); }
130
+ catch { throw new IdpError('IDP_LOCK_CORRUPT', `实例操作锁损坏,禁止自动删除:${lockPath}`); }
131
+ const localDeadProcess = current.hostname === os.hostname() && !processIsAlive(current.pid);
132
+ invariant(localDeadProcess, 'IDP_OPERATION_LOCKED', `实例正在执行${current.operation ?? '未知操作'}(PID ${current.pid ?? 'unknown'})`);
133
+ fs.unlinkSync(lockPath);
134
+ }
135
+ const record = { schemaVersion: 1, id: crypto.randomUUID(), operation, pid: process.pid, hostname: os.hostname(), startedAt: new Date().toISOString() };
136
+ try { createExclusiveFile(lockPath, `${JSON.stringify(record)}\n`, 0o600); }
137
+ catch (error) {
138
+ if (error?.code === 'EEXIST') throw new IdpError('IDP_OPERATION_LOCKED', '实例已被另一个操作锁定');
139
+ throw error;
140
+ }
141
+ try { receiptChain(env); }
142
+ catch (error) {
143
+ releaseInstanceLock({ lockPath, record });
144
+ throw error;
145
+ }
146
+ return { lockPath, record };
147
+ }
148
+
149
+ function releaseInstanceLock({ lockPath, record }) {
150
+ if (!fs.existsSync(lockPath)) return;
151
+ try {
152
+ const current = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
153
+ if (current.id === record.id) fs.unlinkSync(lockPath);
154
+ } catch {}
155
+ }
156
+
157
+ const LIVE_INSTANCE_LEASES = new WeakSet();
158
+ const INSTANCE_LEASE_FACTS = new WeakMap();
159
+
160
+ function canonicalLeaseRoot(candidate, role) {
161
+ invariant(typeof candidate === 'string' && path.isAbsolute(candidate), 'IDP_INSTANCE_LEASE_SCOPE_MISMATCH', `${role}必须是绝对路径`);
162
+ try { return fs.realpathSync.native(candidate); }
163
+ catch (error) { throw new IdpError('IDP_INSTANCE_LEASE_SCOPE_MISMATCH', `${role}无法解析:${error.message}`); }
164
+ }
165
+
166
+ function useInstanceLease(root, env, callback, lease) {
167
+ invariant(lease && (typeof lease === 'object' || typeof lease === 'function'), 'IDP_INSTANCE_LEASE_INVALID', '实例Lease必须由当前进程的外层withInstanceLock签发');
168
+ const facts = INSTANCE_LEASE_FACTS.get(lease);
169
+ invariant(facts, 'IDP_INSTANCE_LEASE_INVALID', '实例Lease不是由当前进程的withInstanceLock签发');
170
+ invariant(LIVE_INSTANCE_LEASES.has(lease), 'IDP_INSTANCE_LEASE_EXPIRED', '实例Lease已过期,不能在外层操作结束后复用');
171
+ const requestedRoot = canonicalLeaseRoot(root, '实例Lease Config Dir');
172
+ const requestedRuntimeRoot = canonicalLeaseRoot(env?.IDP_RUNTIME_DIR, '实例Lease Runtime Dir');
173
+ invariant(
174
+ requestedRoot === facts.root && requestedRuntimeRoot === facts.runtimeRoot,
175
+ 'IDP_INSTANCE_LEASE_SCOPE_MISMATCH',
176
+ '实例Lease只能用于签发它的Config Dir与Runtime Dir',
177
+ );
178
+ invariant(fs.existsSync(facts.lockPath), 'IDP_INSTANCE_LEASE_LOCK_MISMATCH', '实例Lease对应的磁盘锁已不存在');
179
+ try { assertSafeRegularFile(facts.lockPath, 0o600, { allowEmpty: false, role: '实例操作锁' }); }
180
+ catch (error) { throw new IdpError('IDP_INSTANCE_LEASE_LOCK_MISMATCH', `实例Lease对应的磁盘锁不再安全:${error.message}`); }
181
+ let current;
182
+ try { current = JSON.parse(fs.readFileSync(facts.lockPath, 'utf8')); }
183
+ catch { throw new IdpError('IDP_INSTANCE_LEASE_LOCK_MISMATCH', '实例Lease对应的磁盘锁不是有效JSON'); }
184
+ const recordKeys = ['schemaVersion', 'id', 'operation', 'pid', 'hostname', 'startedAt'];
185
+ invariant(
186
+ current && typeof current === 'object' && !Array.isArray(current) &&
187
+ stableJson(Object.keys(current).sort()) === stableJson(recordKeys.sort()) &&
188
+ current.schemaVersion === 1 && current.id === facts.lockId && current.operation === facts.operation &&
189
+ current.pid === process.pid && current.hostname === os.hostname() &&
190
+ sha256(stableJson(current)) === facts.recordDigest,
191
+ 'IDP_INSTANCE_LEASE_LOCK_MISMATCH',
192
+ '实例Lease与当前磁盘锁记录不匹配',
193
+ );
194
+ return callback(lease);
195
+ }
196
+
197
+ export function withInstanceLock(root, env, operation, callback, { lease } = {}) {
198
+ if (lease !== undefined) return useInstanceLease(root, env, callback, lease);
199
+ const lock = acquireInstanceLock(root, env, operation);
200
+ let opaqueLease;
201
+ try {
202
+ opaqueLease = Object.freeze(Object.create(null));
203
+ INSTANCE_LEASE_FACTS.set(opaqueLease, {
204
+ root: canonicalLeaseRoot(root, '实例Lease Config Dir'),
205
+ runtimeRoot: canonicalLeaseRoot(env.IDP_RUNTIME_DIR, '实例Lease Runtime Dir'),
206
+ lockPath: lock.lockPath,
207
+ lockId: lock.record.id,
208
+ operation: lock.record.operation,
209
+ recordDigest: sha256(stableJson(lock.record)),
210
+ });
211
+ LIVE_INSTANCE_LEASES.add(opaqueLease);
212
+ return callback(opaqueLease);
213
+ }
214
+ finally {
215
+ if (opaqueLease) LIVE_INSTANCE_LEASES.delete(opaqueLease);
216
+ releaseInstanceLock(lock);
217
+ }
218
+ }
219
+
220
+ function validateReceipt(receipt) {
221
+ invariant(receipt && typeof receipt === 'object' && !Array.isArray(receipt), 'IDP_RECEIPT_INVALID', '运维回执格式无效');
222
+ invariant(receipt.schemaVersion === 1 && Number.isSafeInteger(receipt.sequence) && receipt.sequence > 0 && /^sha256:[0-9a-f]{64}$/u.test(receipt.digest ?? ''), 'IDP_RECEIPT_INVALID', '运维回执缺少有效序号或摘要');
223
+ const { digest, ...body } = receipt;
224
+ invariant(sha256(stableJson(body)) === digest, 'IDP_RECEIPT_DIGEST_MISMATCH', '运维回执摘要不匹配');
225
+ return receipt;
226
+ }
227
+
228
+ function receiptChain(env) {
229
+ const directory = path.join(env.IDP_RUNTIME_DIR, 'receipts');
230
+ const receipts = fs.readdirSync(directory).filter((name) => name.endsWith('.json')).map((name) => {
231
+ const candidate = path.join(directory, name);
232
+ assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: '运维回执' });
233
+ return { candidate, receipt: validateReceipt(JSON.parse(fs.readFileSync(candidate, 'utf8'))) };
234
+ }).sort((left, right) => left.receipt.sequence - right.receipt.sequence);
235
+ for (const [index, entry] of receipts.entries()) {
236
+ invariant(entry.receipt.sequence === index + 1, 'IDP_RECEIPT_CHAIN_BROKEN', '运维回执序号不连续');
237
+ const expectedPrevious = index === 0 ? null : receipts[index - 1].receipt.digest;
238
+ invariant(entry.receipt.previousReceiptDigest === expectedPrevious, 'IDP_RECEIPT_CHAIN_BROKEN', '运维回执摘要链断裂');
239
+ }
240
+ return receipts;
241
+ }
242
+
243
+ export function readLatestVerifyEvidence(env, { profile, configDigest, renderDigest, activeUpdatedAt }) {
244
+ const latest = receiptChain(env).filter(({ receipt }) => receipt.operation === 'verify').at(-1)?.receipt;
245
+ invariant(latest, 'IDP_BINDING_VERIFY_EVIDENCE_MISSING', '生成Binding前必须先执行一次与当前活动Profile匹配的idpctl verify');
246
+ invariant(
247
+ latest.profile === profile && latest.configDigest === configDigest && latest.renderDigest === renderDigest,
248
+ 'IDP_BINDING_VERIFY_EVIDENCE_MISMATCH',
249
+ '最近一次部署验证回执与当前活动Profile、配置摘要或运行时投影不一致;请重新执行idpctl verify',
250
+ );
251
+ const verifiedAt = typeof latest.at === 'string' ? new Date(latest.at) : new Date(Number.NaN);
252
+ const activatedAt = typeof activeUpdatedAt === 'string' ? new Date(activeUpdatedAt) : new Date(Number.NaN);
253
+ invariant(
254
+ !Number.isNaN(verifiedAt.getTime()) && !Number.isNaN(activatedAt.getTime()) && verifiedAt >= activatedAt,
255
+ 'IDP_BINDING_VERIFY_EVIDENCE_STALE',
256
+ '最近一次部署验证早于当前Profile启用时间;请重新执行idpctl verify',
257
+ );
258
+ const expectedServices = resolveProfileServices(profile).map((service) => ({ service, status: 'healthy' }));
259
+ invariant(
260
+ stableJson(latest.services) === stableJson(expectedServices),
261
+ 'IDP_BINDING_VERIFY_SERVICE_MISMATCH',
262
+ '最近一次部署验证没有证明当前Profile的全部服务健康',
263
+ );
264
+ return latest;
265
+ }
266
+
267
+ export function writeReceipt(env, operation, payload) {
268
+ const chain = receiptChain(env);
269
+ const previous = chain.at(-1)?.receipt;
270
+ const sequence = (previous?.sequence ?? 0) + 1;
271
+ const body = { schemaVersion: 1, sequence, operation, at: new Date().toISOString(), previousReceiptDigest: previous?.digest ?? null, ...payload };
272
+ const receipt = { ...body, digest: sha256(stableJson(body)) };
273
+ const stamp = body.at.replace(/[:.]/gu, '-');
274
+ const candidate = path.join(env.IDP_RUNTIME_DIR, 'receipts', `${String(sequence).padStart(12, '0')}-${stamp}-${operation.replaceAll(':', '-')}-${crypto.randomUUID()}.json`);
275
+ createExclusiveFile(candidate, `${JSON.stringify(receipt, null, 2)}\n`, 0o600);
276
+ return { candidate, receipt };
277
+ }
278
+
279
+ function findBackupEvidence(env, generationId, manifestDigest) {
280
+ for (const { candidate, receipt } of receiptChain(env).reverse()) {
281
+ if (receipt.operation === 'backup' && receipt.generationId === generationId && receipt.manifestDigest === manifestDigest) return { candidate, digest: receipt.digest };
282
+ }
283
+ throw new IdpError('IDP_BACKUP_EVIDENCE_MISSING', '备份缺少独立且摘要匹配的创建回执');
284
+ }
285
+
286
+ function findRestoreApplyEvidence(env, candidateId, candidateDigest) {
287
+ for (const { candidate, receipt } of receiptChain(env).reverse()) {
288
+ if (receipt.operation === 'restore-apply' && receipt.candidateId === candidateId && receipt.candidateDigest === candidateDigest) return { candidate, digest: receipt.digest };
289
+ }
290
+ throw new IdpError('IDP_RESTORE_EVIDENCE_MISSING', '恢复候选缺少独立且摘要匹配的创建回执');
291
+ }
292
+
293
+ function prepareState(root, env, active) {
294
+ if (!active.has('registry')) return;
295
+ const source = resolveContained(root, env.IDP_REGISTRY_HTPASSWD_FILE, 'Registry初始凭据');
296
+ const target = path.join(env.IDP_DATA_DIR, 'registry', 'auth', 'htpasswd');
297
+ if (!fs.existsSync(target)) {
298
+ fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
299
+ fs.chmodSync(target, 0o600);
300
+ } else assertSafeRegularFile(target, 0o600, { allowEmpty: true, role: 'Registry实时凭据文件' });
301
+ }
302
+
303
+ export function composeOperation({ repositoryRoot, configRoot, profile, command, requireConfigured = true, allowProfileSwitch = false, allowLegacyImageLock = false, instanceLease, runner = run }) {
304
+ const { root, env, report } = doctorConfig(configRoot, { requireConfigured, profile, allowLegacyImageLock });
305
+ const active = new Set(resolveProfile(profile));
306
+ return withInstanceLock(root, env, `compose:${command[0]}`, () => {
307
+ prepareState(root, env, active);
308
+ const rendered = renderRuntimeBundles(root, profile);
309
+ const current = readActiveProfileState(env);
310
+ const operation = command[0];
311
+ if (operation === 'up' && current?.status === 'active' && current.profile !== profile) {
312
+ invariant(allowProfileSwitch, 'IDP_PROFILE_SWITCH_REQUIRED', `当前活动Profile为${current.profile};请使用idpctl switch ${profile}显式切换并清理Orphan`);
313
+ }
314
+ if (operation === 'down' && current?.status === 'active') {
315
+ invariant(current.profile === profile, 'IDP_PROFILE_DOWN_MISMATCH', `当前活动Profile为${current.profile},不能用${profile}执行down`);
316
+ }
317
+ const effectiveCommand = [...command];
318
+ if (operation === 'up' && !effectiveCommand.includes('--remove-orphans')) effectiveCommand.push('--remove-orphans');
319
+ if (operation === 'down' && !effectiveCommand.includes('--remove-orphans')) effectiveCommand.push('--remove-orphans');
320
+ const args = composeArgs(repositoryRoot, root, profile, effectiveCommand, { projectName: env.IDP_INSTANCE_ID });
321
+ runner('docker', args, { env: composeProcessEnvironment(root, rendered.target) });
322
+ let profileState = current;
323
+ if (operation === 'up') profileState = writeActiveProfileState(env, { status: 'active', profile, services: resolveProfileServices(profile), configDigest: report.digest, renderDigest: rendered.manifest.digest });
324
+ else if (operation === 'down') profileState = writeActiveProfileState(env, { status: 'stopped', profile, services: resolveProfileServices(profile), configDigest: report.digest, renderDigest: rendered.manifest.digest });
325
+ return writeReceipt(env, operation, {
326
+ profile,
327
+ configDigest: report.digest,
328
+ renderDigest: rendered.manifest.digest,
329
+ profileStateDigest: profileState?.digest ?? null,
330
+ composeArgs: args.map((item) => item.includes(root) ? item.replace(root, '$IDP_CONFIG_DIR') : item),
331
+ });
332
+ }, { lease: instanceLease });
333
+ }
334
+
335
+ function publishGeneration(staging, target) {
336
+ const handle = fs.openSync(staging, 'r');
337
+ try { fs.fsyncSync(handle); } finally { fs.closeSync(handle); }
338
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
339
+ fs.renameSync(staging, target);
340
+ const parentHandle = fs.openSync(path.dirname(target), 'r');
341
+ try { fs.fsyncSync(parentHandle); } finally { fs.closeSync(parentHandle); }
342
+ }
343
+
344
+ const TECH_BACKUP_BOUNDARY_FILE = 'tech-backup-boundary.json';
345
+ const TECH_BACKUP_BOUNDARY_COMMAND = ['/opt/knowledge-service/bin/knowledge-service.mjs', 'backup-boundary'];
346
+
347
+ function strictAscending(values, role, key = (value) => value) {
348
+ let previous;
349
+ for (const value of values) {
350
+ const current = key(value);
351
+ invariant(typeof current === 'string' && (previous === undefined || previous < current), 'IDP_TECH_BACKUP_BOUNDARY_INVALID', `${role}必须唯一并按字典序严格升序排列`);
352
+ previous = current;
353
+ }
354
+ }
355
+
356
+ function validateTechBackupBoundary(document) {
357
+ exactObjectKeys(document, ['schemaVersion', 'generatedAt', 'digest', 'database', 'knowledge', 'objectStorage', 'restoreVerification'], 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech备份边界');
358
+ exactObjectKeys(document.database, ['engine', 'requiredFormat', 'migrations', 'counts'], 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech备份数据库边界');
359
+ exactObjectKeys(document.database.counts, ['spaces', 'sources', 'references', 'snapshots'], 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech备份计数');
360
+ exactObjectKeys(document.knowledge, ['activeIndexRevision', 'sourceRevisions'], 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech知识边界');
361
+ exactObjectKeys(document.objectStorage, ['mode', 'ownedObjects'], 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech对象存储边界');
362
+ const timestamp = typeof document.generatedAt === 'string' ? new Date(document.generatedAt) : new Date(Number.NaN);
363
+ invariant(document.schemaVersion === 'tech.backup-boundary.v1' && !Number.isNaN(timestamp.getTime()) && timestamp.toISOString() === document.generatedAt, 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech备份边界版本或时间无效');
364
+ invariant(document.database.engine === 'postgresql' && document.database.requiredFormat === 'pg_dump-custom', 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech备份数据库格式无效');
365
+ invariant(Array.isArray(document.database.migrations) && document.database.migrations.length > 0 && document.database.migrations.length <= 10_000, 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech migration边界不能为空或超限');
366
+ for (const migration of document.database.migrations) {
367
+ exactObjectKeys(migration, ['version', 'checksum'], 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech migration边界项');
368
+ invariant(typeof migration.version === 'string' && migration.version.length > 0 && migration.version.length <= 256 && /^sha256:[0-9a-f]{64}$/u.test(migration.checksum ?? ''), 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech migration版本或checksum无效');
369
+ }
370
+ strictAscending(document.database.migrations, 'Tech migration版本', (entry) => entry.version);
371
+ for (const [key, value] of Object.entries(document.database.counts)) invariant(Number.isSafeInteger(value) && value >= 0, 'IDP_TECH_BACKUP_BOUNDARY_INVALID', `Tech计数${key}无效`);
372
+ invariant(typeof document.knowledge.activeIndexRevision === 'string' && document.knowledge.activeIndexRevision.length > 0 && document.knowledge.activeIndexRevision.length <= 256, 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech active index revision无效');
373
+ invariant(Array.isArray(document.knowledge.sourceRevisions) && document.knowledge.sourceRevisions.length <= 10_000, 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech来源Revision列表无效或超限');
374
+ for (const source of document.knowledge.sourceRevisions) {
375
+ exactObjectKeys(source, ['sourceId', 'identity', 'repositoryUrl', 'commit', 'treeDigest'], 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech来源Revision');
376
+ invariant(typeof source.sourceId === 'string' && source.sourceId.length > 0 && source.sourceId.length <= 256 && typeof source.identity === 'string' && source.identity.length > 0 && source.identity.length <= 256, 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech来源身份无效');
377
+ invariant(typeof source.repositoryUrl === 'string' && source.repositoryUrl.length > 0 && source.repositoryUrl.length <= 4096 && !/[\r\n\0]/u.test(source.repositoryUrl), 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech来源仓库地址无效');
378
+ const hasRevision = source.commit !== null || source.treeDigest !== null;
379
+ invariant(!hasRevision || (typeof source.commit === 'string' && /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(source.commit) && /^sha256:[0-9a-f]{64}$/u.test(source.treeDigest ?? '')), 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech来源commit与treeDigest必须同时为空或同时有效');
380
+ invariant(hasRevision || (source.commit === null && source.treeDigest === null), 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech来源commit与treeDigest必须同时为空或同时有效');
381
+ }
382
+ strictAscending(document.knowledge.sourceRevisions, 'Tech来源Revision', (entry) => entry.sourceId);
383
+ invariant(document.objectStorage.mode === 'none' && document.objectStorage.ownedObjects === false, 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech对象存储边界必须保持none/false');
384
+ invariant(Array.isArray(document.restoreVerification) && document.restoreVerification.length > 0 && document.restoreVerification.every((value) => typeof value === 'string' && value.length > 0 && value.length <= 512) && new Set(document.restoreVerification).size === document.restoreVerification.length, 'IDP_TECH_BACKUP_BOUNDARY_INVALID', 'Tech恢复验证说明无效');
385
+ const { generatedAt: _generatedAt, digest, ...facts } = document;
386
+ invariant(/^sha256:[0-9a-f]{64}$/u.test(digest ?? '') && sha256(stableJson(facts)) === digest, 'IDP_TECH_BACKUP_BOUNDARY_DIGEST_MISMATCH', 'Tech备份边界事实摘要不匹配');
387
+ return document;
388
+ }
389
+
390
+ function parseTechBackupBoundaryOutput(stdout) {
391
+ let document;
392
+ try { document = JSON.parse(String(Buffer.isBuffer(stdout) ? stdout.toString('utf8') : stdout ?? '').trim()); }
393
+ catch { throw new IdpError('IDP_TECH_BACKUP_BOUNDARY_JSON_INVALID', 'Tech backup-boundary未输出单一有效JSON'); }
394
+ return validateTechBackupBoundary(document);
395
+ }
396
+
397
+ function runStoppedTechBoundary(compose, environment, runner) {
398
+ const result = runner('docker', compose(['run', '--rm', '--no-deps', '--entrypoint', 'node', 'tech', ...TECH_BACKUP_BOUNDARY_COMMAND]), { capture: true, env: environment });
399
+ return parseTechBackupBoundaryOutput(result.stdout);
400
+ }
401
+
402
+ function runLiveTechBoundary(compose, environment, runner) {
403
+ const result = runner('docker', compose(['exec', '-T', 'tech', 'node', ...TECH_BACKUP_BOUNDARY_COMMAND]), { capture: true, env: environment });
404
+ return parseTechBackupBoundaryOutput(result.stdout);
405
+ }
406
+
407
+ function runTechBoundaryForDatabase({ rendered, compose, environment, env, database, expectedDigest, runner }) {
408
+ const temporaryRoot = path.join(env.IDP_RUNTIME_DIR, 'tmp', `tech-boundary-${crypto.randomUUID()}`);
409
+ const sourceRoot = path.join(rendered.target, 'tech');
410
+ fs.cpSync(sourceRoot, temporaryRoot, { recursive: true, errorOnExist: true });
411
+ for (const relative of ['', 'config', 'secrets']) {
412
+ const source = path.join(sourceRoot, relative);
413
+ const target = path.join(temporaryRoot, relative);
414
+ fs.chmodSync(target, fs.statSync(source).mode & 0o777);
415
+ }
416
+ try {
417
+ const databaseFile = path.join(temporaryRoot, 'secrets', 'database-url');
418
+ const original = fs.readFileSync(databaseFile, 'utf8').trim();
419
+ let url;
420
+ try { url = new URL(original); } catch { throw new IdpError('IDP_TECH_DATABASE_URL_INVALID', 'Tech数据库文件不是有效URL'); }
421
+ url.pathname = `/${database}`;
422
+ atomicWrite(databaseFile, `${url.toString()}\n`, 0o400);
423
+ const result = runner('docker', compose(['run', '--rm', '--no-deps', '--volume', `${temporaryRoot}:/run/idp-restore-config:ro`, '--env', 'IDP_COMPONENT_CONFIG_DIR=/run/idp-restore-config', '--entrypoint', 'node', 'tech', ...TECH_BACKUP_BOUNDARY_COMMAND]), { capture: true, env: environment });
424
+ const boundary = parseTechBackupBoundaryOutput(result.stdout);
425
+ invariant(boundary.digest === expectedDigest, 'IDP_TECH_RESTORE_BOUNDARY_MISMATCH', '恢复数据库的migration、计数、active index或来源Revision与备份边界不一致');
426
+ return boundary;
427
+ } finally {
428
+ if (fs.existsSync(temporaryRoot)) fs.rmSync(temporaryRoot, { recursive: true, force: true });
429
+ }
430
+ }
431
+
432
+ function safeErrorCode(error, fallback = 'IDP_PROCESS_FAILED') {
433
+ return /^[A-Z][A-Z0-9_]{0,127}$/u.test(error?.code ?? '') ? error.code : fallback;
434
+ }
435
+
436
+ function emptySmartGoRecovery(profile) {
437
+ return {
438
+ unpause: { required: false, attempted: false, status: 'not-required', attempts: 0, errorCode: null },
439
+ objectStoreHealth: {
440
+ required: false, attempted: false, status: 'not-required', attempts: 0,
441
+ maxAttempts: SMARTGO_BACKUP_RECOVERY_POLICY.maxAttempts,
442
+ waitTimeoutSeconds: SMARTGO_BACKUP_RECOVERY_POLICY.objectStoreWaitTimeoutSeconds,
443
+ errorCode: null,
444
+ },
445
+ profile: {
446
+ required: false, attempted: false, status: 'not-required', attempts: 0,
447
+ maxAttempts: SMARTGO_BACKUP_RECOVERY_POLICY.maxAttempts,
448
+ waitTimeoutSeconds: SMARTGO_BACKUP_RECOVERY_POLICY.profileWaitTimeoutSeconds,
449
+ services: resolveProfileServices(profile), errorCode: null,
450
+ },
451
+ };
452
+ }
453
+
454
+ function boundedComposeHealthWait({ compose, services, waitTimeoutSeconds, runner, environment, waiter }) {
455
+ let lastError;
456
+ for (let attempt = 1; attempt <= SMARTGO_BACKUP_RECOVERY_POLICY.maxAttempts; attempt += 1) {
457
+ try {
458
+ runner('docker', compose(['up', '-d', '--wait', '--wait-timeout', String(waitTimeoutSeconds), ...services]), { env: environment });
459
+ return {
460
+ required: true, attempted: true, status: 'restored', attempts: attempt,
461
+ maxAttempts: SMARTGO_BACKUP_RECOVERY_POLICY.maxAttempts, waitTimeoutSeconds, errorCode: null,
462
+ };
463
+ } catch (error) {
464
+ lastError = error;
465
+ if (attempt < SMARTGO_BACKUP_RECOVERY_POLICY.maxAttempts) waiter(SMARTGO_BACKUP_RECOVERY_POLICY.retryDelayMilliseconds);
466
+ }
467
+ }
468
+ return {
469
+ required: true, attempted: true, status: 'failed', attempts: SMARTGO_BACKUP_RECOVERY_POLICY.maxAttempts,
470
+ maxAttempts: SMARTGO_BACKUP_RECOVERY_POLICY.maxAttempts, waitTimeoutSeconds,
471
+ errorCode: safeErrorCode(lastError),
472
+ };
473
+ }
474
+
475
+ function restoreSmartGoProfileRuntime({ repositoryRoot, root, profile, env, environment, runner, waiter, unpauseRequired }) {
476
+ const compose = (command) => composeArgs(repositoryRoot, root, profile, command, { projectName: env.IDP_INSTANCE_ID });
477
+ const recovery = emptySmartGoRecovery(profile);
478
+ if (unpauseRequired) {
479
+ recovery.unpause = { required: true, attempted: true, status: 'restored', attempts: 1, errorCode: null };
480
+ try { runner('docker', compose(['unpause', 'smartgo-object-store']), { env: environment }); }
481
+ catch (error) {
482
+ // 解除暂停命令可能在服务已经恢复后仍返回非零;最终以严格的全 Profile 健康证明为准,并保留该错误事实。
483
+ recovery.unpause.status = 'failed';
484
+ recovery.unpause.errorCode = safeErrorCode(error);
485
+ }
486
+ }
487
+ recovery.objectStoreHealth = boundedComposeHealthWait({
488
+ compose, services: ['smartgo-object-store'],
489
+ waitTimeoutSeconds: SMARTGO_BACKUP_RECOVERY_POLICY.objectStoreWaitTimeoutSeconds,
490
+ runner, environment, waiter,
491
+ });
492
+ recovery.profile = {
493
+ ...boundedComposeHealthWait({
494
+ compose, services: resolveProfileServices(profile),
495
+ waitTimeoutSeconds: SMARTGO_BACKUP_RECOVERY_POLICY.profileWaitTimeoutSeconds,
496
+ runner, environment, waiter,
497
+ }),
498
+ services: resolveProfileServices(profile),
499
+ };
500
+ return recovery;
501
+ }
502
+
503
+ export function createBackup({ repositoryRoot, configRoot, profile = 'core', instanceLease, runner = run, fileRunner = runToFile, waiter = defaultWaiter, allowLegacyImageLock = false }) {
504
+ const { active, datasets } = profileDatasets(profile);
505
+ const { root, env, report } = doctorConfig(configRoot, { requireConfigured: true, profile, allowLegacyImageLock });
506
+ return withInstanceLock(root, env, `backup:${profile}`, () => {
507
+ prepareState(root, env, active);
508
+ const rendered = renderRuntimeBundles(root, profile);
509
+ const processEnvironment = composeProcessEnvironment(root, rendered.target);
510
+ const generationId = `${new Date().toISOString().replace(/[:.]/gu, '-')}-${report.digest.slice(7, 13)}${crypto.randomBytes(3).toString('hex')}`;
511
+ const backupStagingRoot = secureDirectoryRoot(path.join(env.IDP_BACKUP_DIR, 'staging'), 'Backup staging', { create: true });
512
+ const backupGenerationsRoot = secureDirectoryRoot(path.join(env.IDP_BACKUP_DIR, 'generations'), 'Backup generations', { create: true });
513
+ const backupFailedRoot = secureDirectoryRoot(path.join(env.IDP_BACKUP_DIR, 'failed'), 'Backup failed', { create: true });
514
+ const staging = path.join(backupStagingRoot, `${generationId}-${crypto.randomUUID()}`);
515
+ const target = path.join(backupGenerationsRoot, generationId);
516
+ fs.mkdirSync(staging, { recursive: false, mode: 0o700 });
517
+ let registryPaused = false;
518
+ let smartgoObjectStoreRecoveryRequired = false;
519
+ let smartgoRuntimeRecoveryRequired = false;
520
+ let smartgoRecovery = null;
521
+ let smartgoBackupPhase = active.has('smartgo') ? 'pending' : null;
522
+ let techStopped = false;
523
+ let published = false;
524
+ try {
525
+ const entries = [];
526
+ let techBoundary = null;
527
+ const databaseDatasets = datasets.filter((value) => value.startsWith('postgresql:'));
528
+ if (active.has('tech')) {
529
+ runner('docker', composeArgs(repositoryRoot, root, profile, ['stop', '--timeout', '60', 'tech'], { projectName: env.IDP_INSTANCE_ID }), { env: processEnvironment });
530
+ techStopped = true;
531
+ const compose = (command) => composeArgs(repositoryRoot, root, profile, command, { projectName: env.IDP_INSTANCE_ID });
532
+ const boundary = runStoppedTechBoundary(compose, processEnvironment, runner);
533
+ const boundaryPath = path.join(staging, TECH_BACKUP_BOUNDARY_FILE);
534
+ createExclusiveFile(boundaryPath, `${JSON.stringify(boundary, null, 2)}\n`, 0o600);
535
+ const boundaryStat = assertSafeRegularFile(boundaryPath, 0o600, { allowEmpty: false, role: 'Tech备份边界文件' });
536
+ techBoundary = {
537
+ contract: 'tech.backup-boundary.v1', file: TECH_BACKUP_BOUNDARY_FILE,
538
+ size: boundaryStat.size, fileDigest: hashFile(boundaryPath), factsDigest: boundary.digest,
539
+ };
540
+ const techDataset = databaseDatasets.find((value) => value === 'postgresql:tech');
541
+ invariant(techDataset, 'IDP_TECH_BACKUP_DATASET_MISSING', '包含Tech的Profile必须同时备份tech数据库');
542
+ const output = path.join(staging, 'tech.dump');
543
+ fileRunner('docker', composeArgs(repositoryRoot, root, profile, ['exec', '-T', '-u', 'postgres', 'postgresql', 'pg_dump', '--format=custom', '--dbname', 'tech'], { projectName: env.IDP_INSTANCE_ID }), output, { env: processEnvironment });
544
+ const stat = assertSafeRegularFile(output, 0o600, { allowEmpty: false, role: 'tech数据库备份' });
545
+ entries.push({ dataset: techDataset, kind: 'postgresql', database: 'tech', file: 'tech.dump', size: stat.size, digest: hashFile(output) });
546
+ runner('docker', composeArgs(repositoryRoot, root, profile, ['up', '-d', '--wait', 'tech'], { projectName: env.IDP_INSTANCE_ID }), { env: processEnvironment });
547
+ techStopped = false;
548
+ }
549
+ for (const dataset of databaseDatasets.filter((value) => !['postgresql:tech', 'postgresql:smartgo'].includes(value))) {
550
+ const database = dataset.slice('postgresql:'.length);
551
+ const file = `${database}.dump`;
552
+ const output = path.join(staging, file);
553
+ fileRunner('docker', composeArgs(repositoryRoot, root, profile, ['exec', '-T', '-u', 'postgres', 'postgresql', 'pg_dump', '--format=custom', '--dbname', database], { projectName: env.IDP_INSTANCE_ID }), output, { env: processEnvironment });
554
+ const stat = assertSafeRegularFile(output, 0o600, { allowEmpty: false, role: `${database}数据库备份` });
555
+ entries.push({ dataset, kind: 'postgresql', database, file, size: stat.size, digest: hashFile(output) });
556
+ }
557
+ if (datasets.includes('registry:packages')) {
558
+ runner('docker', composeArgs(repositoryRoot, root, profile, ['pause', 'registry'], { projectName: env.IDP_INSTANCE_ID }), { env: processEnvironment });
559
+ registryPaused = true;
560
+ const file = 'registry.tar.gz';
561
+ const archive = path.join(staging, file);
562
+ runner('tar', ['-C', path.join(env.IDP_DATA_DIR, 'registry'), '-czf', archive, '.']);
563
+ fs.chmodSync(archive, 0o600);
564
+ const stat = assertSafeRegularFile(archive, 0o600, { allowEmpty: false, role: 'Registry备份' });
565
+ entries.push({ dataset: 'registry:packages', kind: 'registry', file, size: stat.size, digest: hashFile(archive) });
566
+ runner('docker', composeArgs(repositoryRoot, root, profile, ['unpause', 'registry'], { projectName: env.IDP_INSTANCE_ID }), { env: processEnvironment });
567
+ registryPaused = false;
568
+ }
569
+ if (active.has('smartgo')) {
570
+ const smartgoDataset = databaseDatasets.find((value) => value === 'postgresql:smartgo');
571
+ invariant(smartgoDataset && datasets.includes('smartgo-object-store:objects'), 'IDP_SMARTGO_BACKUP_DATASET_MISSING', '包含SmartGo的Profile必须同时备份smartgo数据库与对象存储');
572
+ const compose = (command) => composeArgs(repositoryRoot, root, profile, command, { projectName: env.IDP_INSTANCE_ID });
573
+
574
+ smartgoBackupPhase = 'stopping-writers';
575
+ smartgoRuntimeRecoveryRequired = true;
576
+ runner('docker', compose(['stop', '--timeout', '60', ...SMARTGO_WRITER_SERVICES]), { env: processEnvironment });
577
+
578
+ smartgoBackupPhase = 'pausing-object-store';
579
+ smartgoObjectStoreRecoveryRequired = true;
580
+ runner('docker', compose(['pause', 'smartgo-object-store']), { env: processEnvironment });
581
+
582
+ smartgoBackupPhase = 'dumping-database';
583
+ const databaseFile = 'smartgo.dump';
584
+ const databaseOutput = path.join(staging, databaseFile);
585
+ fileRunner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'pg_dump', '--format=custom', '--dbname', 'smartgo']), databaseOutput, { env: processEnvironment });
586
+ const databaseStat = assertSafeRegularFile(databaseOutput, 0o600, { allowEmpty: false, role: 'smartgo数据库备份' });
587
+ entries.push({ dataset: smartgoDataset, kind: 'postgresql', database: 'smartgo', file: databaseFile, size: databaseStat.size, digest: hashFile(databaseOutput) });
588
+
589
+ smartgoBackupPhase = 'archiving-objects';
590
+ const file = 'smartgo-object-store.tar.gz';
591
+ const archive = path.join(staging, file);
592
+ runner('tar', ['-C', path.join(env.IDP_DATA_DIR, 'smartgo', 'object-store'), '-czf', archive, '.']);
593
+ fs.chmodSync(archive, 0o600);
594
+ const stat = assertSafeRegularFile(archive, 0o600, { allowEmpty: false, role: 'SmartGo对象存储备份' });
595
+ entries.push({ dataset: 'smartgo-object-store:objects', kind: 'smartgo-object-store', file, size: stat.size, digest: hashFile(archive) });
596
+
597
+ smartgoBackupPhase = 'validating-object-archive';
598
+ validateSmartGoObjectArchive(archive, runner);
599
+
600
+ smartgoBackupPhase = 'restoring-runtime';
601
+ smartgoRecovery = restoreSmartGoProfileRuntime({
602
+ repositoryRoot, root, profile, env, environment: processEnvironment, runner, waiter,
603
+ unpauseRequired: smartgoObjectStoreRecoveryRequired,
604
+ });
605
+ invariant(
606
+ smartgoRecovery.profile.status === 'restored',
607
+ 'IDP_SMARTGO_RUNTIME_RECOVERY_EXHAUSTED',
608
+ 'SmartGo备份数据已生成,但完整原Profile未能在有界健康等待内恢复',
609
+ );
610
+ smartgoObjectStoreRecoveryRequired = false;
611
+ smartgoRuntimeRecoveryRequired = false;
612
+ smartgoBackupPhase = 'complete';
613
+ }
614
+ const entriesByDataset = new Map(entries.map((entry) => [entry.dataset, entry]));
615
+ invariant(entriesByDataset.size === entries.length && datasets.every((dataset) => entriesByDataset.has(dataset)), 'IDP_BACKUP_DATASET_INCOMPLETE', '备份数据集未完整生成或包含重复项');
616
+ const orderedEntries = datasets.map((dataset) => entriesByDataset.get(dataset));
617
+ const body = {
618
+ schemaVersion: 1,
619
+ generationId,
620
+ profile,
621
+ configDigest: report.digest,
622
+ renderDigest: rendered.manifest.digest,
623
+ expectedDatasets: datasets,
624
+ techBoundary,
625
+ files: orderedEntries,
626
+ createdAt: new Date().toISOString(),
627
+ };
628
+ const manifest = { ...body, manifestDigest: sha256(stableJson(body)) };
629
+ createExclusiveFile(path.join(staging, 'generation.json'), `${JSON.stringify(manifest, null, 2)}\n`, 0o600);
630
+ publishGeneration(staging, target);
631
+ published = true;
632
+ return writeReceipt(env, 'backup', { generationId, profile, target, configDigest: report.digest, manifestDigest: manifest.manifestDigest });
633
+ } catch (error) {
634
+ if (techStopped) {
635
+ try { runner('docker', composeArgs(repositoryRoot, root, profile, ['up', '-d', '--wait', 'tech'], { projectName: env.IDP_INSTANCE_ID }), { env: processEnvironment }); } catch {}
636
+ }
637
+ if (registryPaused) {
638
+ try { runner('docker', composeArgs(repositoryRoot, root, profile, ['unpause', 'registry'], { projectName: env.IDP_INSTANCE_ID }), { env: processEnvironment }); } catch {}
639
+ }
640
+ const originalErrorCode = safeErrorCode(error, 'IDP_BACKUP_FAILED');
641
+ const recovery = smartgoRecovery ?? (
642
+ smartgoRuntimeRecoveryRequired
643
+ ? restoreSmartGoProfileRuntime({
644
+ repositoryRoot, root, profile, env, environment: processEnvironment, runner, waiter,
645
+ unpauseRequired: smartgoObjectStoreRecoveryRequired,
646
+ })
647
+ : emptySmartGoRecovery(profile)
648
+ );
649
+ const source = published ? target : staging;
650
+ let failedTarget;
651
+ if (fs.existsSync(source)) {
652
+ failedTarget = path.join(backupFailedRoot, `${generationId}-${crypto.randomUUID()}`);
653
+ fs.renameSync(source, failedTarget);
654
+ if (active.has('smartgo')) {
655
+ const failedBody = {
656
+ schemaVersion: 'idp.smartgo-backup-failure/v2', generationId, profile,
657
+ failedAt: new Date().toISOString(), phase: smartgoBackupPhase, originalErrorCode, recovery,
658
+ };
659
+ createExclusiveFile(path.join(failedTarget, 'backup-failure.json'), `${JSON.stringify({ ...failedBody, digest: sha256(stableJson(failedBody)) }, null, 2)}\n`, 0o600);
660
+ }
661
+ }
662
+ const recoveryFailed = smartgoRuntimeRecoveryRequired && recovery.profile.status !== 'restored';
663
+ if (recoveryFailed) {
664
+ throw new IdpError('IDP_SMARTGO_BACKUP_RECOVERY_FAILED', 'SmartGo备份失败且完整原Profile未能在有界健康等待内恢复,请按失败证据执行人工恢复', {
665
+ failedTarget, phase: smartgoBackupPhase, originalErrorCode,
666
+ recoveryErrorCode: recovery.profile.errorCode,
667
+ });
668
+ }
669
+ throw error;
670
+ }
671
+ }, { lease: instanceLease });
672
+ }
673
+
674
+ function validateManifest(manifest, generationId) {
675
+ const allowedKeys = ['schemaVersion', 'generationId', 'profile', 'configDigest', 'renderDigest', 'expectedDatasets', 'techBoundary', 'files', 'createdAt', 'manifestDigest'];
676
+ const validObject = manifest && typeof manifest === 'object' && !Array.isArray(manifest) && stableJson(Object.keys(manifest).sort()) === stableJson([...allowedKeys].sort());
677
+ let expected;
678
+ try { expected = profileDatasets(manifest?.profile).datasets; } catch { expected = undefined; }
679
+ const timestamp = typeof manifest?.createdAt === 'string' ? new Date(manifest.createdAt) : new Date(Number.NaN);
680
+ const uniqueDatasets = Array.isArray(manifest?.expectedDatasets) && new Set(manifest.expectedDatasets).size === manifest.expectedDatasets.length;
681
+ const exactDatasets = uniqueDatasets && expected && expected.length === manifest.expectedDatasets.length && expected.every((value, index) => manifest.expectedDatasets[index] === value);
682
+ const { manifestDigest, ...body } = validObject ? manifest : {};
683
+ const expectsTechBoundary = (() => { try { return resolveProfile(manifest?.profile).includes('tech'); } catch { return false; } })();
684
+ if (expectsTechBoundary) {
685
+ exactObjectKeys(manifest.techBoundary, ['contract', 'file', 'size', 'fileDigest', 'factsDigest'], 'IDP_BACKUP_MANIFEST_INVALID', 'Tech备份边界描述');
686
+ invariant(manifest.techBoundary.contract === 'tech.backup-boundary.v1' && manifest.techBoundary.file === TECH_BACKUP_BOUNDARY_FILE && Number.isSafeInteger(manifest.techBoundary.size) && manifest.techBoundary.size > 0 && /^sha256:[0-9a-f]{64}$/u.test(manifest.techBoundary.fileDigest ?? '') && /^sha256:[0-9a-f]{64}$/u.test(manifest.techBoundary.factsDigest ?? ''), 'IDP_BACKUP_MANIFEST_INVALID', 'Tech备份边界描述无效');
687
+ } else invariant(manifest?.techBoundary === null, 'IDP_BACKUP_MANIFEST_INVALID', '不包含Tech的Profile不得声明Tech备份边界');
688
+ invariant(
689
+ validObject && manifest.schemaVersion === 1 && manifest.generationId === generationId &&
690
+ /^sha256:[0-9a-f]{64}$/u.test(manifest.configDigest ?? '') && /^sha256:[0-9a-f]{64}$/u.test(manifest.renderDigest ?? '') &&
691
+ !Number.isNaN(timestamp.getTime()) && timestamp.toISOString() === manifest.createdAt && exactDatasets &&
692
+ Array.isArray(manifest.files) && manifest.files.length === expected.length &&
693
+ /^sha256:[0-9a-f]{64}$/u.test(manifestDigest ?? '') && sha256(stableJson(body)) === manifestDigest,
694
+ 'IDP_BACKUP_MANIFEST_INVALID', '备份代次清单契约或摘要无效',
695
+ );
696
+ return expected;
697
+ }
698
+
699
+ function readVerifiedGeneration(env, generationId) {
700
+ invariant(/^[0-9TZ-]+-[0-9a-f]{12}$/u.test(generationId), 'IDP_BACKUP_ID_INVALID', '备份代次ID格式无效');
701
+ const generationRoot = resolveContained(env.IDP_BACKUP_DIR, `generations/${generationId}`, '备份代次');
702
+ const stat = fs.lstatSync(generationRoot);
703
+ invariant(stat.isDirectory() && !stat.isSymbolicLink(), 'IDP_BACKUP_DIRECTORY_INVALID', '备份代次必须是普通目录');
704
+ const manifestPath = path.join(generationRoot, 'generation.json');
705
+ assertSafeRegularFile(manifestPath, 0o600, { allowEmpty: false, role: '备份代次清单' });
706
+ let manifest;
707
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); }
708
+ catch { throw new IdpError('IDP_BACKUP_MANIFEST_INVALID', '备份代次清单不是有效JSON'); }
709
+ const expectedDatasets = validateManifest(manifest, generationId);
710
+ const seenFiles = new Set();
711
+ const seenDatasets = new Set();
712
+ const verified = [];
713
+ for (const entry of manifest.files) {
714
+ const databaseEntry = entry?.kind === 'postgresql';
715
+ const allowedEntryKeys = databaseEntry ? ['dataset', 'kind', 'database', 'file', 'size', 'digest'] : ['dataset', 'kind', 'file', 'size', 'digest'];
716
+ invariant(
717
+ entry && typeof entry === 'object' && !Array.isArray(entry) && Object.keys(entry).every((key) => allowedEntryKeys.includes(key)) &&
718
+ ['postgresql', 'registry', 'smartgo-object-store'].includes(entry.kind) && expectedDatasets.includes(entry.dataset) && !seenDatasets.has(entry.dataset) &&
719
+ entry.file && path.basename(entry.file) === entry.file && !seenFiles.has(entry.file) &&
720
+ Number.isSafeInteger(entry.size) && entry.size > 0 && /^sha256:[0-9a-f]{64}$/u.test(entry.digest ?? ''),
721
+ 'IDP_BACKUP_ENTRY_INVALID', '备份文件条目无效',
722
+ );
723
+ if (databaseEntry) invariant(ROLE_BY_DATABASE[entry.database] && entry.dataset === `postgresql:${entry.database}` && entry.file === `${entry.database}.dump`, 'IDP_BACKUP_ENTRY_INVALID', 'PostgreSQL备份条目无效');
724
+ else if (entry.kind === 'registry') invariant(entry.dataset === 'registry:packages' && entry.file === 'registry.tar.gz' && !('database' in entry), 'IDP_BACKUP_ENTRY_INVALID', 'Registry备份条目无效');
725
+ else invariant(entry.dataset === 'smartgo-object-store:objects' && entry.file === 'smartgo-object-store.tar.gz' && !('database' in entry), 'IDP_BACKUP_ENTRY_INVALID', 'SmartGo对象存储备份条目无效');
726
+ seenFiles.add(entry.file);
727
+ seenDatasets.add(entry.dataset);
728
+ const candidate = path.join(generationRoot, entry.file);
729
+ const fileStat = assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: `备份文件${entry.file}` });
730
+ invariant(fileStat.size === entry.size, 'IDP_BACKUP_SIZE_MISMATCH', `备份文件大小不匹配:${entry.file}`);
731
+ const actual = hashFile(candidate);
732
+ invariant(actual === entry.digest, 'IDP_BACKUP_DIGEST_MISMATCH', `备份文件摘要不匹配:${entry.file}`);
733
+ verified.push({ dataset: entry.dataset, file: entry.file, size: entry.size, digest: actual });
734
+ }
735
+ invariant(expectedDatasets.every((dataset) => seenDatasets.has(dataset)), 'IDP_BACKUP_MANIFEST_INCOMPLETE', '备份数据集不完整');
736
+ const expectedNames = new Set(['generation.json', ...seenFiles]);
737
+ let techBoundary;
738
+ if (manifest.techBoundary) {
739
+ const candidate = path.join(generationRoot, manifest.techBoundary.file);
740
+ const boundaryStat = assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: 'Tech备份边界文件' });
741
+ invariant(boundaryStat.size === manifest.techBoundary.size && hashFile(candidate) === manifest.techBoundary.fileDigest, 'IDP_TECH_BACKUP_BOUNDARY_FILE_MISMATCH', 'Tech备份边界文件大小或摘要不匹配');
742
+ try { techBoundary = validateTechBackupBoundary(JSON.parse(fs.readFileSync(candidate, 'utf8'))); }
743
+ catch (error) { if (error instanceof IdpError) throw error; throw new IdpError('IDP_TECH_BACKUP_BOUNDARY_JSON_INVALID', 'Tech备份边界文件不是有效JSON'); }
744
+ invariant(techBoundary.digest === manifest.techBoundary.factsDigest, 'IDP_TECH_BACKUP_BOUNDARY_DIGEST_MISMATCH', 'Tech备份边界事实摘要与Generation不一致');
745
+ expectedNames.add(manifest.techBoundary.file);
746
+ }
747
+ for (const name of fs.readdirSync(generationRoot)) invariant(expectedNames.has(name), 'IDP_BACKUP_FILE_UNDECLARED', `备份代次包含清单外文件:${name}`);
748
+ const evidence = findBackupEvidence(env, generationId, manifest.manifestDigest);
749
+ return { generationRoot, manifest, verified, evidence, techBoundary };
750
+ }
751
+
752
+ export function verifyBackup({ configRoot, generationId, instanceLease }) {
753
+ const { root, env } = doctorConfig(configRoot, { requireConfigured: false, profile: 'registry' });
754
+ return withInstanceLock(root, env, 'backup:verify', () => {
755
+ const { manifest, verified, evidence, techBoundary } = readVerifiedGeneration(env, generationId);
756
+ return writeReceipt(env, 'backup-verify', { generationId, profile: manifest.profile, verified, techBoundaryDigest: techBoundary?.digest ?? null, creationEvidenceDigest: evidence.digest });
757
+ }, { lease: instanceLease });
758
+ }
759
+
760
+ function parseDatabaseAcceptance(stdout, database) {
761
+ const values = String(Buffer.isBuffer(stdout) ? stdout.toString('utf8') : stdout ?? '').trim().split('|');
762
+ const tableCount = Number(values[0]);
763
+ const ownedCount = Number(values[1]);
764
+ const canConnect = values[2] === 't';
765
+ invariant(Number.isInteger(tableCount) && tableCount > 0 && ownedCount === tableCount && canConnect, 'IDP_RESTORE_ACCEPTANCE_FAILED', `恢复后的${database}数据库未通过表、Owner与连接权限验收`);
766
+ return { tableCount, ownedCount, canConnect };
767
+ }
768
+
769
+ function acceptanceQuery(role) {
770
+ return `SELECT count(*), count(*) FILTER (WHERE pg_get_userbyid(c.relowner)='${role}'), has_database_privilege('${role}', current_database(), 'CONNECT') FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE c.relkind='r' AND n.nspname NOT IN ('pg_catalog','information_schema');`;
771
+ }
772
+
773
+ function validateRegistryArchive(archive, runner = run) {
774
+ const namesResult = runner('tar', ['-tzf', archive], { capture: true });
775
+ const names = String(Buffer.isBuffer(namesResult.stdout) ? namesResult.stdout.toString('utf8') : namesResult.stdout ?? '').split(/\r?\n/u).filter(Boolean);
776
+ invariant(names.length > 0, 'IDP_REGISTRY_ARCHIVE_EMPTY', 'Registry归档为空');
777
+ for (const name of names) {
778
+ invariant(!path.isAbsolute(name), 'IDP_REGISTRY_ARCHIVE_UNSAFE', `Registry归档包含绝对路径:${name}`);
779
+ const normalized = path.posix.normalize(name.replace(/^\.\//u, ''));
780
+ invariant(normalized !== '..' && !normalized.startsWith('../'), 'IDP_REGISTRY_ARCHIVE_UNSAFE', `Registry归档包含路径逃逸:${name}`);
781
+ }
782
+ invariant(names.some((name) => /^\.\/?auth\/?$/u.test(name)) && names.some((name) => /^\.\/?packages\/?$/u.test(name)), 'IDP_REGISTRY_ARCHIVE_INCOMPLETE', 'Registry归档缺少auth或packages目录');
783
+ const verboseResult = runner('tar', ['-tvzf', archive], { capture: true });
784
+ const verbose = String(Buffer.isBuffer(verboseResult.stdout) ? verboseResult.stdout.toString('utf8') : verboseResult.stdout ?? '').split(/\r?\n/u).filter(Boolean);
785
+ invariant(verbose.length > 0 && verbose.every((line) => ['-', 'd'].includes(line[0])), 'IDP_REGISTRY_ARCHIVE_LINK_FORBIDDEN', 'Registry归档只能包含普通文件和目录,不得包含链接或特殊节点');
786
+ return { entries: names.length };
787
+ }
788
+
789
+ function validateSmartGoObjectArchive(archive, runner = run) {
790
+ const namesResult = runner('tar', ['-tzf', archive], { capture: true });
791
+ const names = String(Buffer.isBuffer(namesResult.stdout) ? namesResult.stdout.toString('utf8') : namesResult.stdout ?? '').split(/\r?\n/u).filter(Boolean);
792
+ invariant(names.length > 0, 'IDP_SMARTGO_OBJECT_ARCHIVE_EMPTY', 'SmartGo对象存储归档为空');
793
+ for (const name of names) {
794
+ invariant(!path.isAbsolute(name), 'IDP_SMARTGO_OBJECT_ARCHIVE_UNSAFE', `SmartGo对象存储归档包含绝对路径:${name}`);
795
+ const normalized = path.posix.normalize(name.replace(/^\.\//u, ''));
796
+ invariant(normalized !== '..' && !normalized.startsWith('../'), 'IDP_SMARTGO_OBJECT_ARCHIVE_UNSAFE', `SmartGo对象存储归档包含路径逃逸:${name}`);
797
+ }
798
+ const verboseResult = runner('tar', ['-tvzf', archive], { capture: true });
799
+ const verbose = String(Buffer.isBuffer(verboseResult.stdout) ? verboseResult.stdout.toString('utf8') : verboseResult.stdout ?? '').split(/\r?\n/u).filter(Boolean);
800
+ invariant(verbose.length > 0 && verbose.every((line) => ['-', 'd'].includes(line[0])) && verbose.some((line) => line[0] === '-'), 'IDP_SMARTGO_OBJECT_ARCHIVE_LINK_FORBIDDEN', 'SmartGo对象存储归档必须包含普通文件,且不得包含链接或特殊节点');
801
+ return { entries: names.length };
802
+ }
803
+
804
+ function restoreDatabaseEntries({ entries, generationRoot, compose, environment, runner, inputRunner, onRestoredDatabase }) {
805
+ const results = [];
806
+ for (const entry of entries) {
807
+ const role = ROLE_BY_DATABASE[entry.database];
808
+ const suffix = `${Date.now()}${Math.floor(Math.random() * 10000)}`.slice(-12);
809
+ const temporaryDatabase = `idpcheck_${entry.database}_${suffix}`.replace(/[^a-z0-9_]/gu, '').slice(0, 63);
810
+ let created = false;
811
+ try {
812
+ runner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'createdb', '--template=template0', '--owner', role, temporaryDatabase]), { env: environment });
813
+ created = true;
814
+ inputRunner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'pg_restore', '--exit-on-error', '--no-owner', '--no-privileges', '--role', role, '--dbname', temporaryDatabase]), path.join(generationRoot, entry.file), { capture: true, env: environment });
815
+ const accepted = runner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'psql', '--tuples-only', '--no-align', '--field-separator=|', '--dbname', temporaryDatabase, '--command', acceptanceQuery(role)]), { capture: true, env: environment });
816
+ const boundaryEvidence = onRestoredDatabase?.({ entry, database: temporaryDatabase }) ?? {};
817
+ results.push({ dataset: entry.dataset, database: entry.database, ...parseDatabaseAcceptance(accepted.stdout, entry.database), ...boundaryEvidence, status: 'passed' });
818
+ } finally {
819
+ if (created) runner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'dropdb', '--if-exists', '--force', temporaryDatabase]), { env: environment });
820
+ }
821
+ }
822
+ return results;
823
+ }
824
+
825
+ export function testRestore({ repositoryRoot, configRoot, generationId, instanceLease, runner = run, inputRunner = runFromFile, allowLegacyImageLock = false }) {
826
+ const preliminary = doctorConfig(configRoot, { requireConfigured: false, profile: 'registry' });
827
+ return withInstanceLock(preliminary.root, preliminary.env, 'restore:test', () => {
828
+ const { generationRoot, manifest, evidence, techBoundary } = readVerifiedGeneration(preliminary.env, generationId);
829
+ const { root, env, report } = doctorConfig(configRoot, { requireConfigured: true, profile: manifest.profile, allowLegacyImageLock });
830
+ const active = new Set(resolveProfile(manifest.profile));
831
+ prepareState(root, env, active);
832
+ const rendered = renderRuntimeBundles(root, manifest.profile);
833
+ const environment = composeProcessEnvironment(root, rendered.target);
834
+ const compose = (command) => composeArgs(repositoryRoot, root, manifest.profile, command, { projectName: env.IDP_INSTANCE_ID });
835
+ const results = restoreDatabaseEntries({
836
+ entries: manifest.files.filter((entry) => entry.kind === 'postgresql'), generationRoot, compose, environment, runner, inputRunner,
837
+ onRestoredDatabase: ({ entry, database }) => {
838
+ if (entry.database !== 'tech') return {};
839
+ invariant(techBoundary, 'IDP_TECH_BACKUP_BOUNDARY_MISSING', 'Tech数据库备份缺少边界清单');
840
+ const restoredBoundary = runTechBoundaryForDatabase({ rendered, compose, environment, env, database, expectedDigest: techBoundary.digest, runner });
841
+ return { backupBoundaryDigest: restoredBoundary.digest, gitReleaseRefetch: 'external-evidence-required' };
842
+ },
843
+ });
844
+ const registryEntry = manifest.files.find((entry) => entry.kind === 'registry');
845
+ if (registryEntry) results.push({ dataset: registryEntry.dataset, ...validateRegistryArchive(path.join(generationRoot, registryEntry.file), runner), status: 'passed' });
846
+ const objectEntry = manifest.files.find((entry) => entry.kind === 'smartgo-object-store');
847
+ if (objectEntry) results.push({ dataset: objectEntry.dataset, ...validateSmartGoObjectArchive(path.join(generationRoot, objectEntry.file), runner), status: 'passed' });
848
+ invariant(results.length === manifest.expectedDatasets.length && manifest.expectedDatasets.every((dataset) => results.some((result) => result.dataset === dataset)), 'IDP_RESTORE_RESULTS_INCOMPLETE', '恢复验收结果与备份数据集不一致');
849
+ return writeReceipt(env, 'restore-test', { generationId, profile: manifest.profile, configDigest: report.digest, creationEvidenceDigest: evidence.digest, results });
850
+ }, { lease: instanceLease });
851
+ }
852
+
853
+ function waitForPostgresql(containerName, runner, waiter) {
854
+ let lastError;
855
+ for (let attempt = 0; attempt < 60; attempt += 1) {
856
+ try {
857
+ runner('docker', ['exec', containerName, 'pg_isready', '--username', 'postgres', '--dbname', 'postgres'], { capture: true });
858
+ return;
859
+ } catch (error) { lastError = error; waiter(500); }
860
+ }
861
+ throw new IdpError('IDP_RESTORE_POSTGRES_TIMEOUT', `恢复用PostgreSQL未能就绪:${lastError?.message ?? 'unknown'}`);
862
+ }
863
+
864
+ function defaultWaiter(milliseconds) {
865
+ const signal = new Int32Array(new SharedArrayBuffer(4));
866
+ Atomics.wait(signal, 0, 0, milliseconds);
867
+ }
868
+
869
+ export function applyRestore({ repositoryRoot, configRoot, generationId, runner = run, inputRunner = runFromFile, waiter = defaultWaiter }) {
870
+ const preliminary = doctorConfig(configRoot, { requireConfigured: false, profile: 'registry' });
871
+ return withInstanceLock(preliminary.root, preliminary.env, 'restore:apply', () => {
872
+ const { generationRoot, manifest, evidence } = readVerifiedGeneration(preliminary.env, generationId);
873
+ const { root, env, report } = doctorConfig(configRoot, { requireConfigured: true, profile: manifest.profile });
874
+ const candidateId = `${generationId}-${crypto.randomUUID()}`;
875
+ const candidatesRoot = secureDirectoryRoot(path.join(env.IDP_RESTORE_DIR, 'candidates'), 'Restore candidates', { create: true });
876
+ const failedRoot = secureDirectoryRoot(path.join(env.IDP_RESTORE_DIR, 'failed'), 'Restore failed', { create: true });
877
+ const candidateRoot = path.join(candidatesRoot, candidateId);
878
+ fs.mkdirSync(candidateRoot, { recursive: false, mode: 0o700 });
879
+ const containerName = `${env.IDP_INSTANCE_ID}-restore-${crypto.randomUUID().slice(0, 8)}`;
880
+ let containerCreated = false;
881
+ try {
882
+ const results = [];
883
+ const registryEntry = manifest.files.find((entry) => entry.kind === 'registry');
884
+ if (registryEntry) {
885
+ const archive = path.join(generationRoot, registryEntry.file);
886
+ const archiveCheck = validateRegistryArchive(archive, runner);
887
+ const registryRoot = path.join(candidateRoot, 'registry');
888
+ fs.mkdirSync(registryRoot, { mode: 0o700 });
889
+ runner('tar', ['-xzf', archive, '-C', registryRoot]);
890
+ results.push({ dataset: registryEntry.dataset, ...archiveCheck, status: 'restored' });
891
+ }
892
+ const objectEntry = manifest.files.find((entry) => entry.kind === 'smartgo-object-store');
893
+ if (objectEntry) {
894
+ const archive = path.join(generationRoot, objectEntry.file);
895
+ const archiveCheck = validateSmartGoObjectArchive(archive, runner);
896
+ const objectRoot = path.join(candidateRoot, 'smartgo', 'object-store');
897
+ fs.mkdirSync(objectRoot, { recursive: true, mode: 0o700 });
898
+ runner('tar', ['-xzf', archive, '-C', objectRoot]);
899
+ results.push({ dataset: objectEntry.dataset, ...archiveCheck, status: 'restored' });
900
+ }
901
+ const databaseEntries = manifest.files.filter((entry) => entry.kind === 'postgresql');
902
+ if (databaseEntries.length > 0) {
903
+ const postgresqlRoot = path.join(candidateRoot, 'postgresql');
904
+ fs.mkdirSync(postgresqlRoot, { mode: 0o700 });
905
+ const mount = (source, target, readonly = false) => `type=bind,source=${source},target=${target}${readonly ? ',readonly' : ''}`;
906
+ const dockerArgs = [
907
+ 'run', '--detach', '--name', containerName, '--security-opt', 'no-new-privileges:true',
908
+ '--env', 'POSTGRES_USER=postgres', '--env', 'POSTGRES_PASSWORD_FILE=/run/secrets/postgresql-superuser-password',
909
+ '--env', 'POSTGRES_INITDB_ARGS=--encoding=UTF8 --auth-host=scram-sha-256 --auth-local=peer',
910
+ '--mount', mount(postgresqlRoot, '/var/lib/postgresql/data'),
911
+ '--mount', mount(resolveContained(root, env.IDP_POSTGRES_SUPERUSER_PASSWORD_FILE), '/run/secrets/postgresql-superuser-password', true),
912
+ '--mount', mount(resolveContained(root, env.IDP_POSTGRES_TECH_PASSWORD_FILE), '/run/secrets/postgresql-tech-password', true),
913
+ '--mount', mount(resolveContained(root, env.IDP_POSTGRES_PORTAL_PASSWORD_FILE), '/run/secrets/postgresql-portal-password', true),
914
+ '--mount', mount(resolveContained(root, env.IDP_POSTGRES_SMARTGO_PASSWORD_FILE), '/run/secrets/postgresql-smartgo-password', true),
915
+ '--mount', mount(path.join(repositoryRoot, 'scripts/postgresql/010-create-databases.sh'), '/docker-entrypoint-initdb.d/010-create-databases.sh', true),
916
+ env.IDP_POSTGRES_IMAGE,
917
+ ];
918
+ runner('docker', dockerArgs, { capture: true });
919
+ containerCreated = true;
920
+ waitForPostgresql(containerName, runner, waiter);
921
+ for (const entry of databaseEntries) {
922
+ const role = ROLE_BY_DATABASE[entry.database];
923
+ inputRunner('docker', ['exec', '-i', '-u', 'postgres', containerName, 'pg_restore', '--exit-on-error', '--no-owner', '--no-privileges', '--role', role, '--dbname', entry.database], path.join(generationRoot, entry.file), { capture: true });
924
+ const accepted = runner('docker', ['exec', '-i', '-u', 'postgres', containerName, 'psql', '--tuples-only', '--no-align', '--field-separator=|', '--dbname', entry.database, '--command', acceptanceQuery(role)], { capture: true });
925
+ results.push({ dataset: entry.dataset, database: entry.database, ...parseDatabaseAcceptance(accepted.stdout, entry.database), status: 'restored' });
926
+ }
927
+ runner('docker', ['stop', '--time', '60', containerName], { capture: true });
928
+ runner('docker', ['rm', containerName], { capture: true });
929
+ containerCreated = false;
930
+ assertSafeRegularFile(path.join(postgresqlRoot, 'PG_VERSION'), 0o644, { allowEmpty: false, role: '恢复候选PG_VERSION' });
931
+ }
932
+ invariant(results.length === manifest.expectedDatasets.length, 'IDP_RESTORE_RESULTS_INCOMPLETE', '恢复候选未覆盖全部数据集');
933
+ const dataDigest = digestCandidateData(candidateRoot);
934
+ const candidateBody = {
935
+ schemaVersion: 1, candidateId, generationId, profile: manifest.profile, status: 'ready-for-cutover',
936
+ preimage: { targetExisted: false, productionDataUntouched: true }, creationEvidenceDigest: evidence.digest,
937
+ configDigest: report.digest, dataDigest, results, createdAt: new Date().toISOString(),
938
+ };
939
+ const candidate = { ...candidateBody, digest: sha256(stableJson(candidateBody)) };
940
+ createExclusiveFile(path.join(candidateRoot, 'candidate.json'), `${JSON.stringify(candidate, null, 2)}\n`, 0o600);
941
+ return writeReceipt(env, 'restore-apply', { generationId, profile: manifest.profile, candidateId, candidateRoot, candidateDigest: candidate.digest, creationEvidenceDigest: evidence.digest });
942
+ } catch (error) {
943
+ if (containerCreated) {
944
+ try { runner('docker', ['rm', '--force', containerName], { capture: true }); } catch {}
945
+ }
946
+ if (fs.existsSync(candidateRoot)) fs.renameSync(candidateRoot, path.join(failedRoot, candidateId));
947
+ throw error;
948
+ }
949
+ });
950
+ }
951
+
952
+ function assertTreeWithoutLinks(root, role) {
953
+ const rootStat = fs.lstatSync(root);
954
+ invariant(rootStat.isDirectory() && !rootStat.isSymbolicLink(), 'IDP_RESTORE_CANDIDATE_INVALID', `${role}必须是普通目录`);
955
+ const stack = [root];
956
+ while (stack.length > 0) {
957
+ const directory = stack.pop();
958
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
959
+ const candidate = path.join(directory, entry.name);
960
+ const stat = fs.lstatSync(candidate);
961
+ invariant(!stat.isSymbolicLink(), 'IDP_RESTORE_CANDIDATE_LINK_FORBIDDEN', `${role}不得包含符号链接:${candidate}`);
962
+ invariant(stat.isDirectory() || stat.isFile(), 'IDP_RESTORE_CANDIDATE_NODE_INVALID', `${role}只能包含目录和普通文件:${candidate}`);
963
+ if (stat.isDirectory()) stack.push(candidate);
964
+ }
965
+ }
966
+ }
967
+
968
+ function candidateDataInventory(root, topLevels) {
969
+ assertTreeWithoutLinks(root, '恢复候选数据');
970
+ const inventory = inventoryDirectory(root).filter((entry) => {
971
+ if (entry.path === 'candidate.json') return false;
972
+ return !topLevels || topLevels.has(entry.path.split(path.sep)[0]);
973
+ });
974
+ invariant(inventory.every((entry) => !entry.unsupported), 'IDP_RESTORE_CANDIDATE_NODE_INVALID', '恢复候选数据只能包含目录和普通文件');
975
+ return inventory;
976
+ }
977
+
978
+ function digestCandidateData(root, topLevels) {
979
+ return sha256(stableJson(candidateDataInventory(root, topLevels)));
980
+ }
981
+
982
+ function digestSubdirectoryAsCandidate(root, prefix) {
983
+ assertTreeWithoutLinks(root, `恢复候选${prefix}数据`);
984
+ const inventory = inventoryDirectory(root).map((entry) => ({ ...entry, path: path.join(prefix, entry.path) }));
985
+ invariant(inventory.every((entry) => !entry.unsupported), 'IDP_RESTORE_CANDIDATE_NODE_INVALID', '恢复候选数据只能包含目录和普通文件');
986
+ return sha256(stableJson(inventory));
987
+ }
988
+
989
+ function exactObjectKeys(value, expected, code, role) {
990
+ invariant(value && typeof value === 'object' && !Array.isArray(value), code, `${role}必须是对象`);
991
+ const actual = Object.keys(value).sort();
992
+ invariant(stableJson(actual) === stableJson([...expected].sort()), code, `${role}字段集合无效`);
993
+ }
994
+
995
+ function readRestoreCandidate(env, candidateId) {
996
+ invariant(/^[0-9TZ-]+-[0-9a-f]{12}-[0-9a-f-]{36}$/u.test(candidateId), 'IDP_RESTORE_CANDIDATE_ID_INVALID', '恢复候选ID格式无效');
997
+ const candidateRoot = resolveContained(env.IDP_RESTORE_DIR, `candidates/${candidateId}`, '恢复候选');
998
+ assertTreeWithoutLinks(candidateRoot, '恢复候选');
999
+ const documentPath = path.join(candidateRoot, 'candidate.json');
1000
+ assertSafeRegularFile(documentPath, 0o600, { allowEmpty: false, role: '恢复候选清单' });
1001
+ let document;
1002
+ try { document = JSON.parse(fs.readFileSync(documentPath, 'utf8')); }
1003
+ catch { throw new IdpError('IDP_RESTORE_CANDIDATE_INVALID', '恢复候选清单不是有效JSON'); }
1004
+ const allowedKeys = ['schemaVersion', 'candidateId', 'generationId', 'profile', 'status', 'preimage', 'creationEvidenceDigest', 'configDigest', 'dataDigest', 'results', 'createdAt', 'digest'];
1005
+ exactObjectKeys(document, allowedKeys, 'IDP_RESTORE_CANDIDATE_INVALID', '恢复候选清单');
1006
+ exactObjectKeys(document.preimage, ['targetExisted', 'productionDataUntouched'], 'IDP_RESTORE_CANDIDATE_INVALID', '恢复候选Preimage');
1007
+ const { digest, ...body } = document;
1008
+ const { datasets } = profileDatasets(document?.profile);
1009
+ const resultDatasets = Array.isArray(document?.results) ? document.results.map((entry) => entry?.dataset) : [];
1010
+ if (Array.isArray(document.results)) {
1011
+ for (const result of document.results) {
1012
+ if (result?.dataset?.startsWith('postgresql:')) {
1013
+ exactObjectKeys(result, ['dataset', 'database', 'tableCount', 'ownedCount', 'canConnect', 'status'], 'IDP_RESTORE_CANDIDATE_INVALID', 'PostgreSQL恢复结果');
1014
+ invariant(ROLE_BY_DATABASE[result.database] && result.dataset === `postgresql:${result.database}` && Number.isSafeInteger(result.tableCount) && result.tableCount > 0 && result.ownedCount === result.tableCount && result.canConnect === true && result.status === 'restored', 'IDP_RESTORE_CANDIDATE_INVALID', 'PostgreSQL恢复结果无效');
1015
+ } else if (result?.dataset === 'registry:packages') {
1016
+ exactObjectKeys(result, ['dataset', 'entries', 'status'], 'IDP_RESTORE_CANDIDATE_INVALID', 'Registry恢复结果');
1017
+ invariant(result.dataset === 'registry:packages' && Number.isSafeInteger(result.entries) && result.entries > 0 && result.status === 'restored', 'IDP_RESTORE_CANDIDATE_INVALID', 'Registry恢复结果无效');
1018
+ } else {
1019
+ exactObjectKeys(result, ['dataset', 'entries', 'status'], 'IDP_RESTORE_CANDIDATE_INVALID', 'SmartGo对象存储恢复结果');
1020
+ invariant(result.dataset === 'smartgo-object-store:objects' && Number.isSafeInteger(result.entries) && result.entries > 0 && result.status === 'restored', 'IDP_RESTORE_CANDIDATE_INVALID', 'SmartGo对象存储恢复结果无效');
1021
+ }
1022
+ }
1023
+ }
1024
+ invariant(
1025
+ document.schemaVersion === 1 && document.candidateId === candidateId && document.status === 'ready-for-cutover' &&
1026
+ document.preimage?.targetExisted === false && document.preimage?.productionDataUntouched === true &&
1027
+ /^sha256:[0-9a-f]{64}$/u.test(document.creationEvidenceDigest ?? '') && /^sha256:[0-9a-f]{64}$/u.test(document.configDigest ?? '') && /^sha256:[0-9a-f]{64}$/u.test(document.dataDigest ?? '') &&
1028
+ Array.isArray(document.results) && new Set(resultDatasets).size === resultDatasets.length && resultDatasets.length === datasets.length && datasets.every((dataset) => resultDatasets.includes(dataset)) &&
1029
+ /^sha256:[0-9a-f]{64}$/u.test(digest ?? '') && sha256(stableJson(body)) === digest,
1030
+ 'IDP_RESTORE_CANDIDATE_INVALID', '恢复候选清单契约、数据集或摘要无效',
1031
+ );
1032
+ invariant(digestCandidateData(candidateRoot) === document.dataDigest, 'IDP_RESTORE_CANDIDATE_DATA_MISMATCH', '恢复候选物理数据摘要不匹配');
1033
+ if (datasets.some((dataset) => dataset.startsWith('postgresql:'))) {
1034
+ assertSafeRegularFile(path.join(candidateRoot, 'postgresql', 'PG_VERSION'), 0o644, { allowEmpty: false, role: '恢复候选PG_VERSION' });
1035
+ }
1036
+ if (datasets.includes('registry:packages')) {
1037
+ for (const directory of ['registry/auth', 'registry/packages']) {
1038
+ const stat = fs.lstatSync(path.join(candidateRoot, directory));
1039
+ invariant(stat.isDirectory() && !stat.isSymbolicLink(), 'IDP_RESTORE_CANDIDATE_INVALID', `恢复候选缺少${directory}`);
1040
+ }
1041
+ }
1042
+ if (datasets.includes('smartgo-object-store:objects')) {
1043
+ const objectRoot = path.join(candidateRoot, 'smartgo', 'object-store');
1044
+ const objectStat = fs.lstatSync(objectRoot);
1045
+ invariant(objectStat.isDirectory() && !objectStat.isSymbolicLink(), 'IDP_RESTORE_CANDIDATE_INVALID', '恢复候选缺少SmartGo对象存储目录');
1046
+ }
1047
+ const evidence = findRestoreApplyEvidence(env, candidateId, document.digest);
1048
+ return { candidateRoot, document, evidence };
1049
+ }
1050
+
1051
+ export function verifyRestoreCandidate({ configRoot, candidateId }) {
1052
+ const { root, env } = doctorConfig(configRoot, { requireConfigured: false, profile: 'registry' });
1053
+ return withInstanceLock(root, env, 'restore:candidate-verify', () => {
1054
+ const { candidateRoot, document } = readRestoreCandidate(env, candidateId);
1055
+ return writeReceipt(env, 'restore-candidate-verify', { candidateId, candidateRoot, candidateDigest: document.digest, profile: document.profile });
1056
+ });
1057
+ }
1058
+
1059
+ function promotionDirectories(profile) {
1060
+ const { datasets } = profileDatasets(profile);
1061
+ const postgresqlDatasets = datasets.filter((dataset) => dataset.startsWith('postgresql:'));
1062
+ if (postgresqlDatasets.length > 0) {
1063
+ invariant(
1064
+ postgresqlDatasets.includes('postgresql:tech') && postgresqlDatasets.includes('postgresql:backstage'),
1065
+ 'IDP_RESTORE_PARTIAL_POSTGRESQL_PROMOTION_FORBIDDEN',
1066
+ '单数据库备份可用于隔离恢复演练,但不能替换共享PostgreSQL物理数据;生产切换必须使用同时包含tech与backstage的core/full备份',
1067
+ );
1068
+ }
1069
+ const directories = [];
1070
+ if (postgresqlDatasets.length > 0) directories.push('postgresql');
1071
+ if (datasets.includes('registry:packages')) directories.push('registry');
1072
+ if (datasets.includes('smartgo-object-store:objects')) directories.push('smartgo');
1073
+ return directories;
1074
+ }
1075
+
1076
+ function sqlIdentifier(value) {
1077
+ invariant(/^[a-z][a-z0-9_]{0,62}$/u.test(value), 'IDP_DATABASE_IDENTIFIER_INVALID', `数据库标识符无效:${value}`);
1078
+ return `"${value}"`;
1079
+ }
1080
+
1081
+ function logicalDatabasePromotion({ repositoryRoot, root, env, report, candidateRoot, document, evidence, runner, inputRunner }) {
1082
+ const { generationRoot, manifest, techBoundary } = readVerifiedGeneration(env, document.generationId);
1083
+ const databaseEntries = manifest.files.filter((entry) => entry.kind === 'postgresql');
1084
+ invariant(databaseEntries.length === 1 && !manifest.files.some((entry) => entry.kind === 'registry'), 'IDP_LOGICAL_PROMOTION_PROFILE_INVALID', '逻辑数据库切换只接受单数据库且不混合Registry的备份代次');
1085
+ const entry = databaseEntries[0];
1086
+ const role = ROLE_BY_DATABASE[entry.database];
1087
+ const application = entry.database === 'tech' ? 'tech' : entry.database === 'smartgo' ? 'smartgo-api' : 'portal';
1088
+ const state = readActiveProfileState(env);
1089
+ const operationProfile = state?.status === 'active' ? state.profile : document.profile;
1090
+ const operationServices = new Set(resolveProfile(operationProfile));
1091
+ invariant(operationServices.has('postgresql') && operationServices.has(application), 'IDP_LOGICAL_PROMOTION_ACTIVE_PROFILE_INVALID', `当前活动Profile ${operationProfile}不包含${application}与PostgreSQL`);
1092
+ const rendered = renderRuntimeBundles(root, operationProfile);
1093
+ const environment = composeProcessEnvironment(root, rendered.target);
1094
+ const compose = (command) => composeArgs(repositoryRoot, root, operationProfile, command, { projectName: env.IDP_INSTANCE_ID });
1095
+ const suffix = crypto.randomUUID().replaceAll('-', '').slice(0, 12);
1096
+ const restoreDatabase = `idp_restore_${entry.database}_${suffix}`.slice(0, 63);
1097
+ const preimageDatabase = `idp_preimage_${entry.database}_${suffix}`.slice(0, 63);
1098
+ const failedDatabase = `idp_failed_${entry.database}_${suffix}`.slice(0, 63);
1099
+ const psql = (sql, options = {}) => runner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'psql', '--set', 'ON_ERROR_STOP=1', '--dbname', 'postgres', '--command', sql]), { capture: true, env: environment, ...options });
1100
+ const terminate = (...databases) => psql(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IN (${databases.map((name) => `'${name}'`).join(',')}) AND pid <> pg_backend_pid();`);
1101
+ const planBody = {
1102
+ schemaVersion: 1, planId: crypto.randomUUID(), operation: 'restore-promote-logical-database', candidateId: document.candidateId,
1103
+ candidateDigest: document.digest, profile: document.profile, activeProfile: operationProfile, database: entry.database,
1104
+ restoreDatabase, preimageDatabase, failedDatabase, configDigest: report.digest, createdAt: new Date().toISOString(),
1105
+ };
1106
+ const plan = { ...planBody, digest: sha256(stableJson(planBody)) };
1107
+ const planPath = path.join(env.IDP_RUNTIME_DIR, 'evidence', `${plan.planId}-logical-database-promotion-plan.json`);
1108
+ createExclusiveFile(planPath, `${JSON.stringify(plan, null, 2)}\n`, 0o600);
1109
+ let restoreCreated = false;
1110
+ let productionRenamed = false;
1111
+ let replacementRenamed = false;
1112
+ let servicesStopped = false;
1113
+ try {
1114
+ runner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'createdb', '--template=template0', '--owner', role, restoreDatabase]), { env: environment });
1115
+ restoreCreated = true;
1116
+ inputRunner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'pg_restore', '--exit-on-error', '--no-owner', '--no-privileges', '--role', role, '--dbname', restoreDatabase]), path.join(generationRoot, entry.file), { capture: true, env: environment });
1117
+ const accepted = runner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'psql', '--tuples-only', '--no-align', '--field-separator=|', '--dbname', restoreDatabase, '--command', acceptanceQuery(role)]), { capture: true, env: environment });
1118
+ const acceptance = parseDatabaseAcceptance(accepted.stdout, entry.database);
1119
+ let restoredTechBoundary;
1120
+ if (entry.database === 'tech') {
1121
+ invariant(techBoundary, 'IDP_TECH_BACKUP_BOUNDARY_MISSING', 'Tech数据库备份缺少边界清单');
1122
+ restoredTechBoundary = runTechBoundaryForDatabase({ rendered, compose, environment, env, database: restoreDatabase, expectedDigest: techBoundary.digest, runner });
1123
+ }
1124
+ const stopServices = entry.database === 'tech'
1125
+ ? ['tech', 'flow', 'portal', 'edge'].filter((service) => operationServices.has(service))
1126
+ : [application, ...(operationServices.has('edge') ? ['edge'] : [])];
1127
+ runner('docker', compose(['stop', '--timeout', '60', ...stopServices]), { env: environment });
1128
+ servicesStopped = true;
1129
+ terminate(entry.database, restoreDatabase);
1130
+ psql(`ALTER DATABASE ${sqlIdentifier(entry.database)} RENAME TO ${sqlIdentifier(preimageDatabase)};`);
1131
+ productionRenamed = true;
1132
+ psql(`ALTER DATABASE ${sqlIdentifier(restoreDatabase)} RENAME TO ${sqlIdentifier(entry.database)};`);
1133
+ replacementRenamed = true;
1134
+ if (entry.database === 'tech') {
1135
+ runner('docker', compose(['up', '-d', '--wait', 'tech']), { env: environment });
1136
+ servicesStopped = false;
1137
+ const liveBoundary = runLiveTechBoundary(compose, environment, runner);
1138
+ invariant(liveBoundary.digest === techBoundary.digest, 'IDP_TECH_RESTORE_BOUNDARY_MISMATCH', '切换后的Tech生产数据库与备份边界不一致');
1139
+ }
1140
+ runner('docker', compose(['up', '-d', '--wait']), { env: environment });
1141
+ servicesStopped = false;
1142
+ return writeReceipt(env, 'restore-promote', {
1143
+ candidateId: document.candidateId, profile: document.profile, activeProfile: operationProfile,
1144
+ candidateDigest: document.digest, creationEvidenceDigest: evidence.digest, planDigest: plan.digest,
1145
+ replacementStrategy: 'logical-database-rename', database: entry.database, preimageDatabase,
1146
+ acceptance, techBoundaryDigest: restoredTechBoundary?.digest ?? null,
1147
+ gitReleaseRefetch: entry.database === 'tech' ? 'external-evidence-required' : null,
1148
+ candidateRoot, status: 'promoted-and-healthy',
1149
+ });
1150
+ } catch (error) {
1151
+ let rollbackError;
1152
+ try {
1153
+ if (replacementRenamed) {
1154
+ if (!servicesStopped) {
1155
+ runner('docker', compose(['stop', '--timeout', '60', ...stopServices]), { env: environment });
1156
+ servicesStopped = true;
1157
+ }
1158
+ terminate(entry.database, preimageDatabase);
1159
+ psql(`ALTER DATABASE ${sqlIdentifier(entry.database)} RENAME TO ${sqlIdentifier(failedDatabase)};`);
1160
+ psql(`ALTER DATABASE ${sqlIdentifier(preimageDatabase)} RENAME TO ${sqlIdentifier(entry.database)};`);
1161
+ productionRenamed = false;
1162
+ } else if (productionRenamed) {
1163
+ psql(`ALTER DATABASE ${sqlIdentifier(preimageDatabase)} RENAME TO ${sqlIdentifier(entry.database)};`);
1164
+ productionRenamed = false;
1165
+ if (restoreCreated) runner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'dropdb', '--if-exists', '--force', restoreDatabase]), { env: environment });
1166
+ } else if (restoreCreated) {
1167
+ runner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'dropdb', '--if-exists', '--force', restoreDatabase]), { env: environment });
1168
+ }
1169
+ if (servicesStopped) runner('docker', compose(['up', '-d', '--wait']), { env: environment });
1170
+ } catch (failure) { rollbackError = failure; }
1171
+ if (rollbackError) throw new IdpError('IDP_LOGICAL_RESTORE_ROLLBACK_FAILED', `逻辑数据库切换失败且Preimage回滚未完成:${rollbackError.message}`, { originalError: error.message, preimageDatabase });
1172
+ if (replacementRenamed) throw new IdpError('IDP_LOGICAL_RESTORE_ROLLED_BACK', `逻辑数据库切换失败,已恢复数据库Preimage:${error.message}`, { preimageDatabase, failedDatabase });
1173
+ throw new IdpError('IDP_LOGICAL_RESTORE_PREPARE_FAILED', `逻辑数据库切换在生产重命名前失败:${error.message}`);
1174
+ }
1175
+ }
1176
+
1177
+ function smartGoCoordinatedPromotion({ repositoryRoot, root, env, report, candidateRoot, document, evidence, runner, inputRunner }) {
1178
+ const { generationRoot, manifest } = readVerifiedGeneration(env, document.generationId);
1179
+ const databaseEntries = manifest.files.filter((entry) => entry.kind === 'postgresql');
1180
+ const objectEntries = manifest.files.filter((entry) => entry.kind === 'smartgo-object-store');
1181
+ invariant(databaseEntries.length === 1 && databaseEntries[0].database === 'smartgo' && objectEntries.length === 1 && manifest.files.length === 2, 'IDP_SMARTGO_PROMOTION_PROFILE_INVALID', 'SmartGo协调切换只接受smartgo数据库与对象存储组成的完整备份代次');
1182
+ const databaseEntry = databaseEntries[0];
1183
+ const state = readActiveProfileState(env);
1184
+ const operationProfile = state?.status === 'active' ? state.profile : document.profile;
1185
+ const operationServices = new Set(resolveProfileServices(operationProfile));
1186
+ invariant(operationServices.has('postgresql') && SMARTGO_WRITER_SERVICES.every((service) => operationServices.has(service)) && operationServices.has('smartgo-object-store'), 'IDP_SMARTGO_PROMOTION_ACTIVE_PROFILE_INVALID', `当前活动Profile ${operationProfile}不包含完整SmartGo运行边界`);
1187
+ const rendered = renderRuntimeBundles(root, operationProfile);
1188
+ const environment = composeProcessEnvironment(root, rendered.target);
1189
+ const compose = (command) => composeArgs(repositoryRoot, root, operationProfile, command, { projectName: env.IDP_INSTANCE_ID });
1190
+ const suffix = crypto.randomUUID().replaceAll('-', '').slice(0, 12);
1191
+ const restoreDatabase = `idp_restore_smartgo_${suffix}`.slice(0, 63);
1192
+ const preimageDatabase = `idp_preimage_smartgo_${suffix}`.slice(0, 63);
1193
+ const failedDatabase = `idp_failed_smartgo_${suffix}`.slice(0, 63);
1194
+ const psql = (sql, options = {}) => runner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'psql', '--set', 'ON_ERROR_STOP=1', '--dbname', 'postgres', '--command', sql]), { capture: true, env: environment, ...options });
1195
+ const terminate = (...databases) => psql(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IN (${databases.map((name) => `'${name}'`).join(',')}) AND pid <> pg_backend_pid();`);
1196
+ const sourceSmartGo = path.join(candidateRoot, 'smartgo');
1197
+ const dataRoot = env.IDP_DATA_DIR;
1198
+ const productionSmartGo = path.join(dataRoot, 'smartgo');
1199
+ const stagingSmartGo = path.join(dataRoot, `.smartgo-restore-staging-${suffix}-${crypto.randomUUID()}`);
1200
+ const preimageSmartGo = path.join(dataRoot, `smartgo.preimage-${suffix}-${crypto.randomUUID()}`);
1201
+ const failedSmartGo = path.join(dataRoot, `smartgo.failed-${suffix}-${crypto.randomUUID()}`);
1202
+ assertTreeWithoutLinks(sourceSmartGo, 'SmartGo恢复候选对象数据');
1203
+ const planBody = {
1204
+ schemaVersion: 1, planId: crypto.randomUUID(), operation: 'restore-promote-smartgo-coordinated', candidateId: document.candidateId,
1205
+ candidateDigest: document.digest, profile: document.profile, activeProfile: operationProfile,
1206
+ database: 'smartgo', restoreDatabase, preimageDatabase, failedDatabase,
1207
+ productionSmartGo, stagingSmartGo, preimageSmartGo, failedSmartGo,
1208
+ configDigest: report.digest, createdAt: new Date().toISOString(),
1209
+ };
1210
+ const plan = { ...planBody, digest: sha256(stableJson(planBody)) };
1211
+ createExclusiveFile(path.join(env.IDP_RUNTIME_DIR, 'evidence', `${plan.planId}-smartgo-promotion-plan.json`), `${JSON.stringify(plan, null, 2)}\n`, 0o600);
1212
+ let restoreCreated = false;
1213
+ let servicesStopped = false;
1214
+ let productionDatabaseRenamed = false;
1215
+ let replacementDatabaseRenamed = false;
1216
+ let productionObjectsRenamed = false;
1217
+ let replacementObjectsInstalled = false;
1218
+ try {
1219
+ fs.cpSync(sourceSmartGo, stagingSmartGo, { recursive: true, errorOnExist: true, preserveTimestamps: true });
1220
+ fs.chmodSync(stagingSmartGo, 0o700);
1221
+ assertTreeWithoutLinks(stagingSmartGo, 'SmartGo恢复暂存对象数据');
1222
+ invariant(stableJson(inventoryDirectory(stagingSmartGo)) === stableJson(inventoryDirectory(sourceSmartGo)), 'IDP_SMARTGO_RESTORE_STAGING_DIGEST_MISMATCH', 'SmartGo对象存储暂存副本与已验证候选不一致');
1223
+ runner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'createdb', '--template=template0', '--owner', 'smartgo', restoreDatabase]), { env: environment });
1224
+ restoreCreated = true;
1225
+ inputRunner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'pg_restore', '--exit-on-error', '--no-owner', '--no-privileges', '--role', 'smartgo', '--dbname', restoreDatabase]), path.join(generationRoot, databaseEntry.file), { capture: true, env: environment });
1226
+ const accepted = runner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'psql', '--tuples-only', '--no-align', '--field-separator=|', '--dbname', restoreDatabase, '--command', acceptanceQuery('smartgo')]), { capture: true, env: environment });
1227
+ const acceptance = parseDatabaseAcceptance(accepted.stdout, 'smartgo');
1228
+ runner('docker', compose(['stop', '--timeout', '60', ...SMARTGO_WRITER_SERVICES, 'smartgo-object-store']), { env: environment });
1229
+ servicesStopped = true;
1230
+ terminate('smartgo', restoreDatabase);
1231
+ psql(`ALTER DATABASE ${sqlIdentifier('smartgo')} RENAME TO ${sqlIdentifier(preimageDatabase)};`);
1232
+ productionDatabaseRenamed = true;
1233
+ psql(`ALTER DATABASE ${sqlIdentifier(restoreDatabase)} RENAME TO ${sqlIdentifier('smartgo')};`);
1234
+ replacementDatabaseRenamed = true;
1235
+ assertTreeWithoutLinks(productionSmartGo, 'SmartGo生产对象数据');
1236
+ runner('sync', []);
1237
+ fs.renameSync(productionSmartGo, preimageSmartGo);
1238
+ productionObjectsRenamed = true;
1239
+ fs.renameSync(stagingSmartGo, productionSmartGo);
1240
+ replacementObjectsInstalled = true;
1241
+ runner('docker', compose(['up', '-d', '--wait', 'smartgo-object-store', ...SMARTGO_WRITER_SERVICES]), { env: environment });
1242
+ servicesStopped = false;
1243
+ return writeReceipt(env, 'restore-promote', {
1244
+ candidateId: document.candidateId, profile: document.profile, activeProfile: operationProfile,
1245
+ candidateDigest: document.digest, creationEvidenceDigest: evidence.digest, planDigest: plan.digest,
1246
+ replacementStrategy: 'smartgo-coordinated-database-object-cutover', database: 'smartgo', preimageDatabase,
1247
+ objectPreimage: preimageSmartGo, acceptance, candidateRoot, status: 'promoted-and-healthy',
1248
+ });
1249
+ } catch (error) {
1250
+ let rollbackError;
1251
+ try {
1252
+ if (!servicesStopped && (replacementDatabaseRenamed || replacementObjectsInstalled)) {
1253
+ runner('docker', compose(['stop', '--timeout', '60', ...SMARTGO_WRITER_SERVICES, 'smartgo-object-store']), { env: environment });
1254
+ servicesStopped = true;
1255
+ }
1256
+ if (replacementDatabaseRenamed) {
1257
+ terminate('smartgo', preimageDatabase);
1258
+ psql(`ALTER DATABASE ${sqlIdentifier('smartgo')} RENAME TO ${sqlIdentifier(failedDatabase)};`);
1259
+ psql(`ALTER DATABASE ${sqlIdentifier(preimageDatabase)} RENAME TO ${sqlIdentifier('smartgo')};`);
1260
+ productionDatabaseRenamed = false;
1261
+ } else if (productionDatabaseRenamed) {
1262
+ psql(`ALTER DATABASE ${sqlIdentifier(preimageDatabase)} RENAME TO ${sqlIdentifier('smartgo')};`);
1263
+ productionDatabaseRenamed = false;
1264
+ } else if (restoreCreated) {
1265
+ runner('docker', compose(['exec', '-T', '-u', 'postgres', 'postgresql', 'dropdb', '--if-exists', '--force', restoreDatabase]), { env: environment });
1266
+ }
1267
+ if (replacementObjectsInstalled && fs.existsSync(productionSmartGo)) fs.renameSync(productionSmartGo, failedSmartGo);
1268
+ if (productionObjectsRenamed && fs.existsSync(preimageSmartGo)) fs.renameSync(preimageSmartGo, productionSmartGo);
1269
+ if (servicesStopped) runner('docker', compose(['up', '-d', '--wait', 'smartgo-object-store', ...SMARTGO_WRITER_SERVICES]), { env: environment });
1270
+ } catch (rollbackFailure) { rollbackError = rollbackFailure; }
1271
+ if (fs.existsSync(stagingSmartGo)) fs.rmSync(stagingSmartGo, { recursive: true, force: true });
1272
+ if (rollbackError) throw new IdpError('IDP_SMARTGO_RESTORE_ROLLBACK_FAILED', `SmartGo协调切换失败且Preimage回滚未完成:${rollbackError.message}`, { originalError: error.message, preimageDatabase, preimageSmartGo });
1273
+ if (replacementDatabaseRenamed || replacementObjectsInstalled) throw new IdpError('IDP_SMARTGO_RESTORE_ROLLED_BACK', `SmartGo协调切换失败,已恢复数据库与对象Preimage:${error.message}`, { preimageDatabase, preimageSmartGo });
1274
+ throw new IdpError('IDP_SMARTGO_RESTORE_PREPARE_FAILED', `SmartGo协调切换在生产替换前失败:${error.message}`);
1275
+ }
1276
+ }
1277
+
1278
+ export function promoteRestore({ repositoryRoot, configRoot, candidateId, confirmation, runner = run, inputRunner = runFromFile }) {
1279
+ invariant(confirmation === candidateId, 'IDP_RESTORE_CONFIRMATION_REQUIRED', '生产切换必须用--confirm提供完全相同的恢复候选ID');
1280
+ const preliminary = doctorConfig(configRoot, { requireConfigured: false, profile: 'registry' });
1281
+ return withInstanceLock(preliminary.root, preliminary.env, 'restore:promote', () => {
1282
+ const { candidateRoot, document } = readRestoreCandidate(preliminary.env, candidateId);
1283
+ const { root, env, report } = doctorConfig(configRoot, { requireConfigured: true, profile: document.profile });
1284
+ invariant(document.configDigest === report.digest, 'IDP_RESTORE_CONFIG_DRIFT', '恢复候选创建后的配置已变化,必须基于当前配置重新执行restore apply');
1285
+ const datasets = profileDatasets(document.profile).datasets;
1286
+ if (document.profile === 'business-smartgo') {
1287
+ return smartGoCoordinatedPromotion({ repositoryRoot, root, env, report, candidateRoot, document, evidence: findRestoreApplyEvidence(env, candidateId, document.digest), runner, inputRunner });
1288
+ }
1289
+ if (datasets.length === 1 && datasets[0].startsWith('postgresql:')) {
1290
+ return logicalDatabasePromotion({ repositoryRoot, root, env, report, candidateRoot, document, evidence: findRestoreApplyEvidence(env, candidateId, document.digest), runner, inputRunner });
1291
+ }
1292
+ const active = new Set(resolveProfile(document.profile));
1293
+ const verifiedGeneration = readVerifiedGeneration(env, document.generationId);
1294
+ const replacementDirectories = promotionDirectories(document.profile);
1295
+ const dataRoot = env.IDP_DATA_DIR;
1296
+ const registryOnly = document.profile === 'registry';
1297
+ const productionTarget = registryOnly ? path.join(dataRoot, 'registry') : dataRoot;
1298
+ const parent = path.dirname(productionTarget);
1299
+ const stamp = new Date().toISOString().replace(/[:.]/gu, '-');
1300
+ const staging = path.join(parent, `.${path.basename(productionTarget)}.restore-staging-${crypto.randomUUID()}`);
1301
+ const preimage = path.join(parent, `${path.basename(productionTarget)}.preimage-${stamp}-${crypto.randomUUID()}`);
1302
+ const planBody = {
1303
+ schemaVersion: 1, planId: crypto.randomUUID(), operation: 'restore-promote', candidateId,
1304
+ candidateDigest: document.digest, profile: document.profile, dataRoot, productionTarget, staging, preimage,
1305
+ replacementStrategy: registryOnly ? 'registry-directory' : 'complete-idp-data-root',
1306
+ replacementDirectories,
1307
+ preconditions: { confirmationMatched: true, candidateVerified: true, productionStoppedBeforeApply: true },
1308
+ configDigest: report.digest, createdAt: new Date().toISOString(),
1309
+ };
1310
+ const plan = { ...planBody, digest: sha256(stableJson(planBody)) };
1311
+ const planPath = path.join(env.IDP_RUNTIME_DIR, 'evidence', `${plan.planId}-restore-promotion-plan.json`);
1312
+ createExclusiveFile(planPath, `${JSON.stringify(plan, null, 2)}\n`, 0o600);
1313
+
1314
+ const rendered = renderRuntimeBundles(root, document.profile);
1315
+ const environment = composeProcessEnvironment(root, rendered.target);
1316
+ const compose = (command) => composeArgs(repositoryRoot, root, document.profile, command, { projectName: env.IDP_INSTANCE_ID });
1317
+ let productionMoved = false;
1318
+ let replacementInstalled = false;
1319
+ let servicesStopped = false;
1320
+ try {
1321
+ servicesStopped = true;
1322
+ runner('docker', compose(['down']), { env: environment });
1323
+ if (registryOnly) {
1324
+ const source = path.join(candidateRoot, 'registry');
1325
+ assertTreeWithoutLinks(productionTarget, '生产Registry数据');
1326
+ assertTreeWithoutLinks(source, '恢复候选Registry数据');
1327
+ fs.cpSync(source, staging, { recursive: true, errorOnExist: true, preserveTimestamps: true });
1328
+ fs.chmodSync(staging, 0o700);
1329
+ invariant(digestSubdirectoryAsCandidate(staging, 'registry') === document.dataDigest, 'IDP_RESTORE_STAGING_DIGEST_MISMATCH', '生产切换暂存数据与已验证恢复候选不一致');
1330
+ } else {
1331
+ assertTreeWithoutLinks(dataRoot, '生产数据根');
1332
+ fs.cpSync(dataRoot, staging, { recursive: true, errorOnExist: true, preserveTimestamps: true });
1333
+ fs.chmodSync(staging, 0o700);
1334
+ for (const directory of replacementDirectories) {
1335
+ const source = path.join(candidateRoot, directory);
1336
+ const sourceStat = fs.lstatSync(source);
1337
+ invariant(sourceStat.isDirectory() && !sourceStat.isSymbolicLink(), 'IDP_RESTORE_CANDIDATE_INVALID', `恢复候选缺少${directory}数据目录`);
1338
+ const target = path.join(staging, directory);
1339
+ if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true });
1340
+ fs.cpSync(source, target, { recursive: true, errorOnExist: true, preserveTimestamps: true });
1341
+ }
1342
+ assertTreeWithoutLinks(staging, '生产切换暂存副本');
1343
+ invariant(digestCandidateData(staging, new Set(replacementDirectories)) === document.dataDigest, 'IDP_RESTORE_STAGING_DIGEST_MISMATCH', '生产切换暂存数据与已验证恢复候选不一致');
1344
+ }
1345
+ runner('sync', []);
1346
+ fs.renameSync(productionTarget, preimage);
1347
+ productionMoved = true;
1348
+ fs.renameSync(staging, productionTarget);
1349
+ replacementInstalled = true;
1350
+ ensureRuntimeRoots(root, env);
1351
+ prepareState(root, env, active);
1352
+ if (active.has('tech')) {
1353
+ invariant(verifiedGeneration.techBoundary, 'IDP_TECH_BACKUP_BOUNDARY_MISSING', 'Tech数据库备份缺少边界清单');
1354
+ runner('docker', compose(['up', '-d', '--wait', 'postgresql', 'tech']), { env: environment });
1355
+ const liveBoundary = runLiveTechBoundary(compose, environment, runner);
1356
+ invariant(liveBoundary.digest === verifiedGeneration.techBoundary.digest, 'IDP_TECH_RESTORE_BOUNDARY_MISMATCH', '切换后的Tech生产数据库与备份边界不一致');
1357
+ }
1358
+ runner('docker', compose(['up', '-d', '--wait']), { env: environment });
1359
+ servicesStopped = false;
1360
+ return writeReceipt(env, 'restore-promote', {
1361
+ candidateId, profile: document.profile, candidateDigest: document.digest, planDigest: plan.digest,
1362
+ preimage, productionTarget, replacementDirectories,
1363
+ techBoundaryDigest: verifiedGeneration.techBoundary?.digest ?? null,
1364
+ gitReleaseRefetch: active.has('tech') ? 'external-evidence-required' : null,
1365
+ status: 'promoted-and-healthy',
1366
+ });
1367
+ } catch (error) {
1368
+ let rollbackError;
1369
+ try {
1370
+ if (replacementInstalled) {
1371
+ try { runner('docker', compose(['down']), { env: environment }); } catch {}
1372
+ const failedReplacement = path.join(parent, `${path.basename(productionTarget)}.failed-restore-${stamp}-${crypto.randomUUID()}`);
1373
+ if (fs.existsSync(productionTarget)) fs.renameSync(productionTarget, failedReplacement);
1374
+ }
1375
+ if (productionMoved && fs.existsSync(preimage)) fs.renameSync(preimage, productionTarget);
1376
+ if (servicesStopped || productionMoved) {
1377
+ ensureRuntimeRoots(root, env);
1378
+ prepareState(root, env, active);
1379
+ runner('docker', compose(['up', '-d', '--wait']), { env: environment });
1380
+ servicesStopped = false;
1381
+ }
1382
+ } catch (rollbackFailure) { rollbackError = rollbackFailure; }
1383
+ if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
1384
+ if (rollbackError) throw new IdpError('IDP_RESTORE_ROLLBACK_FAILED', `恢复切换失败且生产Preimage回滚未完成:${rollbackError.message}`, { originalError: error.message, preimage });
1385
+ if (!productionMoved) throw new IdpError('IDP_RESTORE_PROMOTION_PREPARE_FAILED', `恢复切换在替换生产数据前失败,原服务已恢复:${error.message}`);
1386
+ throw new IdpError('IDP_RESTORE_PROMOTION_ROLLED_BACK', `恢复切换失败,已恢复生产Preimage:${error.message}`, { preimage });
1387
+ }
1388
+ });
1389
+ }
1390
+
1391
+ function parseComposePs(stdout) {
1392
+ const text = Buffer.isBuffer(stdout) ? stdout.toString('utf8') : String(stdout ?? '');
1393
+ try {
1394
+ const value = JSON.parse(text);
1395
+ return Array.isArray(value) ? value : [value];
1396
+ } catch {
1397
+ return text.split(/\r?\n/u).filter(Boolean).map((line) => JSON.parse(line));
1398
+ }
1399
+ }
1400
+
1401
+ function netAuthority(address) {
1402
+ return address.includes(':') && !address.startsWith('[') ? `[${address}]` : address;
1403
+ }
1404
+
1405
+ function probeSmartGoApplication({ name, host, port, healthPath, runner, probes }) {
1406
+ const authority = netAuthority(host);
1407
+ const origin = `http://${authority}:${port}`;
1408
+ const curlBase = ['--fail', '--silent', '--show-error', '--max-time', '10'];
1409
+ runner('curl', [...curlBase, `${origin}${healthPath}`], { capture: true });
1410
+ probes.push({ kind: 'http', target: `${name}/health`, status: 'passed' });
1411
+ const homepage = runner('curl', [...curlBase, `${origin}/`], { capture: true });
1412
+ const html = String(Buffer.isBuffer(homepage.stdout) ? homepage.stdout.toString('utf8') : homepage.stdout ?? '');
1413
+ const match = html.match(/(?:src|href)=["']([^"']*\/_next\/[^"']+)["']/u);
1414
+ invariant(match, 'IDP_SMARTGO_NEXT_ASSET_MISSING', `${name}首页没有引用可验证的Next.js静态资源`);
1415
+ const asset = new URL(match[1].replaceAll('&amp;', '&'), `${origin}/`);
1416
+ invariant(asset.origin === origin && asset.pathname.startsWith('/_next/'), 'IDP_SMARTGO_NEXT_ASSET_ORIGIN_INVALID', `${name}静态资源没有保持独立Origin与根路径`);
1417
+ runner('curl', [...curlBase, asset.toString()], { capture: true });
1418
+ probes.push({ kind: 'http', target: `${name}/next-asset`, status: 'passed' });
1419
+ }
1420
+
1421
+ export function verifyDeployment({ repositoryRoot, configRoot, profile, instanceLease, runner = run, allowLegacyImageLock = false }) {
1422
+ const { root, env, components, report } = doctorConfig(configRoot, { requireConfigured: true, profile, allowLegacyImageLock });
1423
+ const active = new Set(resolveProfile(profile));
1424
+ return withInstanceLock(root, env, 'deployment:verify', () => {
1425
+ prepareState(root, env, active);
1426
+ const rendered = renderRuntimeBundles(root, profile);
1427
+ const processEnvironment = composeProcessEnvironment(root, rendered.target);
1428
+ const args = composeArgs(repositoryRoot, root, profile, ['ps', '--format', 'json'], { projectName: env.IDP_INSTANCE_ID });
1429
+ const result = runner('docker', args, { capture: true, env: processEnvironment });
1430
+ const services = parseComposePs(result.stdout);
1431
+ const expected = resolveProfileServices(profile);
1432
+ const actualNames = services.map((entry) => entry.Service).filter(Boolean).sort();
1433
+ invariant(stableJson(actualNames) === stableJson([...expected].sort()), 'IDP_SERVICE_SET_MISMATCH', `运行服务集合与Profile不一致;expected=${[...expected].sort().join(',')} actual=${actualNames.join(',')}`);
1434
+ const state = readActiveProfileState(env);
1435
+ if (state) {
1436
+ invariant(state.status === 'active' && state.profile === profile, 'IDP_PROFILE_STATE_MISMATCH', `活动Profile状态与待验收Profile不一致:${state.status}/${state.profile}`);
1437
+ invariant(state.configDigest === report.digest, 'IDP_DEPLOYED_CONFIG_DRIFT', '当前Config Dir已变化但运行服务尚未切换到新配置');
1438
+ invariant(state.renderDigest === rendered.manifest.digest, 'IDP_DEPLOYED_RENDER_DRIFT', '运行服务使用的配置投影与当前确定性投影不一致');
1439
+ }
1440
+ for (const serviceName of expected) {
1441
+ const service = services.find((entry) => entry.Service === serviceName);
1442
+ invariant(service, 'IDP_SERVICE_MISSING', `部署缺少服务:${serviceName}`);
1443
+ invariant(String(service.State).toLowerCase() === 'running', 'IDP_SERVICE_NOT_RUNNING', `服务未运行:${serviceName}`);
1444
+ invariant(String(service.Health).toLowerCase() === 'healthy', 'IDP_SERVICE_UNHEALTHY', `服务没有通过健康检查:${serviceName}`);
1445
+ }
1446
+ const probes = [];
1447
+ if (expected.includes('edge')) {
1448
+ const tls = env.IDP_TLS_ENABLED === 'true';
1449
+ const port = tls ? env.IDP_HTTPS_PORT : env.IDP_HTTP_PORT;
1450
+ const bind = tls ? env.IDP_HTTPS_BIND : env.IDP_HTTP_BIND;
1451
+ const host = ['0.0.0.0', '::'].includes(bind) ? '127.0.0.1' : bind;
1452
+ const publicHost = env.IDP_PUBLIC_HOST.replace(/^\[|\]$/gu, '');
1453
+ const publicAuthority = netAuthority(publicHost);
1454
+ const curlBase = ['--fail', '--silent', '--show-error', '--max-time', '10'];
1455
+ if (publicHost !== host.replace(/^\[|\]$/gu, '')) curlBase.push('--resolve', `${publicHost}:${port}:${host}`);
1456
+ if (tls) curlBase.push('--cacert', resolveContained(root, env.IDP_TLS_CERT_FILE, 'TLS证书'));
1457
+ const paths = [['edge/live', '/live']];
1458
+ if (expected.includes('flow')) paths.push(['flow/live', '/flow/live']);
1459
+ if (expected.includes('tech')) paths.push(['tech/live', '/knowledge/live'], ['tech/ready', '/knowledge/ready']);
1460
+ if (expected.includes('portal')) paths.push(['portal/readiness', '/.backstage/health/v1/readiness']);
1461
+ for (const [target, probePath] of paths) {
1462
+ runner('curl', [...curlBase, `${tls ? 'https' : 'http'}://${publicAuthority}:${port}${probePath}`], { capture: true });
1463
+ probes.push({ kind: 'http', target, status: 'passed' });
1464
+ }
1465
+ if (expected.includes('smartgo-api')) {
1466
+ const smartgoHost = components.smartgo.SMARTGO_BIND_HOST;
1467
+ for (const application of [
1468
+ { name: 'smartgo/gotology', port: env.IDP_SMARTGO_GOTOLOGY_PORT, healthPath: '/api/health' },
1469
+ { name: 'smartgo/operations', port: env.IDP_SMARTGO_OPERATIONS_PORT, healthPath: '/api/health' },
1470
+ { name: 'smartgo/studio', port: env.IDP_SMARTGO_STUDIO_PORT, healthPath: '/api/health?ready=1' },
1471
+ ]) probeSmartGoApplication({ ...application, host: smartgoHost, runner, probes });
1472
+ }
1473
+ }
1474
+ if (expected.includes('registry')) {
1475
+ const bind = ['0.0.0.0', '::'].includes(env.IDP_REGISTRY_BIND) ? '127.0.0.1' : env.IDP_REGISTRY_BIND;
1476
+ runner('curl', ['--fail', '--silent', '--show-error', '--max-time', '10', `http://${netAuthority(bind)}:${env.IDP_REGISTRY_PORT}/-/ping`], { capture: true });
1477
+ probes.push({ kind: 'http', target: 'registry/-/ping', status: 'passed' });
1478
+ }
1479
+ return writeReceipt(env, 'verify', {
1480
+ profile, configDigest: report.digest, renderDigest: rendered.manifest.digest,
1481
+ services: expected.map((service) => ({ service, status: 'healthy' })), probes,
1482
+ });
1483
+ }, { lease: instanceLease });
1484
+ }