@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.
- package/README.md +471 -0
- package/bin/idpctl.mjs +8 -0
- package/compose/edge.yaml +61 -0
- package/compose/flow.yaml +34 -0
- package/compose/portal.yaml +38 -0
- package/compose/postgresql.yaml +62 -0
- package/compose/registry.yaml +35 -0
- package/compose/smartgo.yaml +271 -0
- package/compose/tech.yaml +64 -0
- package/contracts/active-profile.schema.json +19 -0
- package/contracts/asset-lifecycle.schema.json +49 -0
- package/contracts/backup-generation.schema.json +60 -0
- package/contracts/component-runtime.schema.json +32 -0
- package/contracts/config-release.schema.json +33 -0
- package/contracts/deployment-evidence.schema.json +43 -0
- package/contracts/deployment-plan.schema.json +67 -0
- package/contracts/release-candidate.schema.json +33 -0
- package/contracts/release-defaults.schema.json +56 -0
- package/contracts/restore-candidate.schema.json +63 -0
- package/contracts/smartgo-component-config.schema.json +79 -0
- package/contracts/tech-backup-boundary.schema.json +61 -0
- package/contracts/tech-source-credentials.schema.json +17 -0
- package/deploy.sh +5 -0
- package/docs/restore-runbook.md +56 -0
- package/governance/asset-lifecycle.v1.json +79 -0
- package/package.json +42 -0
- package/release/defaults.v1.json +64 -0
- package/src/acceptance.mjs +127 -0
- package/src/bindings.mjs +518 -0
- package/src/cli.mjs +360 -0
- package/src/compose.mjs +19 -0
- package/src/config.mjs +903 -0
- package/src/delivery.mjs +123 -0
- package/src/errors.mjs +12 -0
- package/src/foundation-contracts.mjs +128 -0
- package/src/foundation.mjs +107 -0
- package/src/gitops.mjs +285 -0
- package/src/hash.mjs +47 -0
- package/src/image-lock.mjs +160 -0
- package/src/images.mjs +215 -0
- package/src/lifecycle.mjs +58 -0
- package/src/local-source.mjs +354 -0
- package/src/oci-mirror.mjs +10 -0
- package/src/operations.mjs +1484 -0
- package/src/process.mjs +46 -0
- package/src/profiles.mjs +41 -0
- package/src/release.mjs +1760 -0
- package/src/render.mjs +330 -0
- package/src/security.mjs +156 -0
- package/src/source-contracts.mjs +106 -0
- package/src/sources.mjs +78 -0
- package/src/workbench-projects.mjs +118 -0
package/src/bindings.mjs
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { doctorConfig, registryPrivateScopes } from './config.mjs';
|
|
5
|
+
import { IdpError, invariant } from './errors.mjs';
|
|
6
|
+
import { inventoryDirectory, sha256, stableJson } from './hash.mjs';
|
|
7
|
+
import { readActiveProfileState, readLatestVerifyEvidence, withInstanceLock } from './operations.mjs';
|
|
8
|
+
import { resolveProfile, resolveProfileServices } from './profiles.mjs';
|
|
9
|
+
import { assertSafeDirectory, assertSafeRegularFile, createExclusiveFile, resolveContained } from './security.mjs';
|
|
10
|
+
|
|
11
|
+
const MANAGED_DIRECTORY = 'bindings/idp-deploy';
|
|
12
|
+
const MANIFEST_FILE = '.idp-deploy-managed.json';
|
|
13
|
+
const MANIFEST_KEYS = Object.freeze([
|
|
14
|
+
'activeProfileDigest', 'configDigest', 'digest', 'environment', 'files', 'instanceId',
|
|
15
|
+
'owner', 'planDigest', 'profile', 'schemaVersion', 'verifyReceiptDigest',
|
|
16
|
+
]);
|
|
17
|
+
const ROLLBACK_TOKEN_KEYS = Object.freeze([
|
|
18
|
+
'action', 'digest', 'expectedCurrentDigest', 'preimage', 'profile', 'schemaVersion', 'target',
|
|
19
|
+
]);
|
|
20
|
+
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
21
|
+
|
|
22
|
+
function assertOwnedDirectory(candidate, role) {
|
|
23
|
+
const stat = assertSafeDirectory(candidate, 0o700, { role });
|
|
24
|
+
invariant((stat.mode & 0o777) === 0o700, 'IDP_BINDING_DIRECTORY_MODE_INVALID', `${role}权限必须固定为0700:${candidate}`);
|
|
25
|
+
if (typeof process.getuid === 'function') {
|
|
26
|
+
invariant(stat.uid === process.getuid(), 'IDP_BINDING_DIRECTORY_OWNER_INVALID', `${role}必须由当前部署用户拥有:${candidate}`);
|
|
27
|
+
}
|
|
28
|
+
return stat;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assertOwnedRegularFile(candidate, role) {
|
|
32
|
+
const stat = assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role });
|
|
33
|
+
invariant((stat.mode & 0o777) === 0o600, 'IDP_BINDING_FILE_MODE_INVALID', `${role}权限必须固定为0600:${candidate}`);
|
|
34
|
+
if (typeof process.getuid === 'function') {
|
|
35
|
+
invariant(stat.uid === process.getuid(), 'IDP_BINDING_FILE_OWNER_INVALID', `${role}必须由当前部署用户拥有:${candidate}`);
|
|
36
|
+
}
|
|
37
|
+
return stat;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ensurePrivateDirectory(candidate, role) {
|
|
41
|
+
if (!fs.existsSync(candidate)) fs.mkdirSync(candidate, { recursive: true, mode: 0o700 });
|
|
42
|
+
assertOwnedDirectory(candidate, role);
|
|
43
|
+
fs.chmodSync(candidate, 0o700);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fsyncDirectory(candidate) {
|
|
47
|
+
const handle = fs.openSync(candidate, 'r');
|
|
48
|
+
try { fs.fsyncSync(handle); } finally { fs.closeSync(handle); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function exactKeys(value, expected, code, role) {
|
|
52
|
+
invariant(
|
|
53
|
+
value && typeof value === 'object' && !Array.isArray(value) &&
|
|
54
|
+
stableJson(Object.keys(value).sort()) === stableJson([...expected].sort()),
|
|
55
|
+
code,
|
|
56
|
+
`${role}字段集合无效`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function urlAuthority(host) {
|
|
61
|
+
const unwrapped = String(host).replace(/^\[|\]$/gu, '');
|
|
62
|
+
return unwrapped.includes(':') ? `[${unwrapped}]` : unwrapped;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function bindingId(instanceId, suffix) {
|
|
66
|
+
const value = `${instanceId}-${suffix}`;
|
|
67
|
+
invariant(/^[a-z0-9][a-z0-9.-]{0,127}$/u.test(value), 'IDP_BINDING_ID_INVALID', `生成的Binding ID不符合v1合同:${value}`);
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function assertFileReference(reference, prefix, role) {
|
|
72
|
+
invariant(
|
|
73
|
+
typeof reference === 'string' && reference.startsWith(`${prefix}/`) && !path.isAbsolute(reference) && !reference.split('/').includes('..'),
|
|
74
|
+
'IDP_BINDING_FILE_REFERENCE_INVALID',
|
|
75
|
+
`${role}必须是Config Dir内以${prefix}/开头的安全相对路径`,
|
|
76
|
+
);
|
|
77
|
+
return reference;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeBinding({ id, consumer, environment, capability, offering, profile, configuration, fileRefs = {} }) {
|
|
81
|
+
invariant(/^[a-z0-9][a-z0-9.-]{0,127}$/u.test(consumer), 'IDP_BINDING_CONSUMER_INVALID', `Binding consumer不符合v1合同:${consumer}`);
|
|
82
|
+
invariant(/^[a-z0-9][a-z0-9.-]{0,127}$/u.test(environment), 'IDP_BINDING_ENVIRONMENT_INVALID', `Binding environment不符合v1合同:${environment}`);
|
|
83
|
+
return {
|
|
84
|
+
apiVersion: 'infrastructure.bench.dev/v1',
|
|
85
|
+
kind: 'ServiceBinding',
|
|
86
|
+
metadata: { id, consumer, environment },
|
|
87
|
+
spec: { capability, offering, profile, configuration, fileRefs },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function buildManagedBindings({ env, activeServices }) {
|
|
92
|
+
const active = new Set(activeServices);
|
|
93
|
+
const bindings = [];
|
|
94
|
+
if (active.has('tech') && active.has('edge')) {
|
|
95
|
+
const tlsEnabled = env.IDP_TLS_ENABLED === 'true';
|
|
96
|
+
const scheme = tlsEnabled ? 'https' : 'http';
|
|
97
|
+
const port = tlsEnabled ? env.IDP_HTTPS_PORT : env.IDP_HTTP_PORT;
|
|
98
|
+
const publicBaseUrl = `${scheme}://${urlAuthority(env.IDP_PUBLIC_HOST)}:${port}/knowledge`;
|
|
99
|
+
const parsed = new URL(publicBaseUrl);
|
|
100
|
+
invariant(!parsed.username && !parsed.password && !parsed.search && !parsed.hash, 'IDP_BINDING_EDGE_URL_INVALID', 'Tech Edge Binding URL不能包含凭据、查询参数或片段');
|
|
101
|
+
const fileRefs = tlsEnabled ? {
|
|
102
|
+
certs: { certificate: assertFileReference(env.IDP_TLS_CERT_FILE, 'certs', 'TLS证书Binding引用') },
|
|
103
|
+
secrets: { privateKey: assertFileReference(env.IDP_TLS_KEY_FILE, 'secrets', 'TLS私钥Binding引用') },
|
|
104
|
+
} : {};
|
|
105
|
+
bindings.push({
|
|
106
|
+
path: 'tech/edge.yaml',
|
|
107
|
+
document: makeBinding({
|
|
108
|
+
id: bindingId(env.IDP_INSTANCE_ID, 'tech-edge'), consumer: 'tech', environment: env.IDP_ENVIRONMENT,
|
|
109
|
+
capability: 'network.edge-http-tls', offering: 'edge.standard-http-tls', profile: 'edge.http-tls',
|
|
110
|
+
configuration: { publicBaseUrl, httpEnabled: env.IDP_HTTP_ENABLED === 'true', tlsEnabled }, fileRefs,
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (active.has('tech') && active.has('postgresql')) {
|
|
115
|
+
bindings.push({
|
|
116
|
+
path: 'tech/postgresql.yaml',
|
|
117
|
+
document: makeBinding({
|
|
118
|
+
id: bindingId(env.IDP_INSTANCE_ID, 'tech-postgresql'), consumer: 'tech', environment: env.IDP_ENVIRONMENT,
|
|
119
|
+
capability: 'data.postgresql', offering: 'postgresql.standard', profile: 'postgresql.database',
|
|
120
|
+
configuration: { endpoint: 'postgresql', port: 5432, database: 'tech', username: 'tech', sslMode: 'disable', authMode: 'password' },
|
|
121
|
+
fileRefs: { secrets: { password: assertFileReference(env.IDP_POSTGRES_TECH_PASSWORD_FILE, 'secrets', 'Tech PostgreSQL密码Binding引用') } },
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (active.has('registry')) {
|
|
126
|
+
const registryUrl = `http://${urlAuthority(env.IDP_REGISTRY_BIND)}:${env.IDP_REGISTRY_PORT}`;
|
|
127
|
+
const parsed = new URL(registryUrl);
|
|
128
|
+
invariant(!parsed.username && !parsed.password && !parsed.search && !parsed.hash, 'IDP_BINDING_REGISTRY_URL_INVALID', 'Registry Binding URL不能包含凭据、查询参数或片段');
|
|
129
|
+
bindings.push({
|
|
130
|
+
path: 'bench/npm.yaml',
|
|
131
|
+
document: makeBinding({
|
|
132
|
+
id: bindingId(env.IDP_INSTANCE_ID, 'bench-npm'), consumer: 'bench', environment: env.IDP_ENVIRONMENT,
|
|
133
|
+
capability: 'artifact.npm-registry', offering: 'npm.standard-registry', profile: 'npm.registry',
|
|
134
|
+
configuration: { registryUrl, scopes: registryPrivateScopes(env), authMode: 'anonymous-read', alwaysAuth: false },
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return bindings.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function yamlScalar(value) {
|
|
142
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
143
|
+
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
|
|
144
|
+
if (Array.isArray(value)) return JSON.stringify(value);
|
|
145
|
+
throw new IdpError('IDP_BINDING_YAML_VALUE_INVALID', '生成Binding时遇到不支持的YAML值');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function yamlObject(value, depth = 0) {
|
|
149
|
+
const lines = [];
|
|
150
|
+
const prefix = ' '.repeat(depth);
|
|
151
|
+
for (const [key, child] of Object.entries(value)) {
|
|
152
|
+
invariant(/^[A-Za-z][A-Za-z0-9]*$/u.test(key), 'IDP_BINDING_YAML_KEY_INVALID', `生成Binding时遇到不安全的YAML键:${key}`);
|
|
153
|
+
if (child && typeof child === 'object' && !Array.isArray(child)) {
|
|
154
|
+
const keys = Object.keys(child);
|
|
155
|
+
if (keys.length === 0) lines.push(`${prefix}${key}: {}`);
|
|
156
|
+
else lines.push(`${prefix}${key}:`, ...yamlObject(child, depth + 1));
|
|
157
|
+
} else lines.push(`${prefix}${key}: ${yamlScalar(child)}`);
|
|
158
|
+
}
|
|
159
|
+
return lines;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function renderServiceBinding(document) {
|
|
163
|
+
return `${yamlObject(document).join('\n')}\n`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function validateManagedDirectory(target) {
|
|
167
|
+
try {
|
|
168
|
+
assertOwnedDirectory(target, 'idp-deploy受管Binding目录');
|
|
169
|
+
const manifestPath = path.join(target, MANIFEST_FILE);
|
|
170
|
+
assertOwnedRegularFile(manifestPath, 'idp-deploy受管Binding Manifest');
|
|
171
|
+
let manifest;
|
|
172
|
+
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); }
|
|
173
|
+
catch { throw new IdpError('IDP_BINDING_MANAGED_DRIFT', '受管Binding Manifest不是有效JSON,拒绝覆盖'); }
|
|
174
|
+
invariant(
|
|
175
|
+
manifest && typeof manifest === 'object' && !Array.isArray(manifest) && stableJson(Object.keys(manifest).sort()) === stableJson([...MANIFEST_KEYS].sort()),
|
|
176
|
+
'IDP_BINDING_MANAGED_DRIFT', '受管Binding Manifest字段已变化,拒绝覆盖',
|
|
177
|
+
);
|
|
178
|
+
const { digest, ...body } = manifest;
|
|
179
|
+
const { planDigest, ...planState } = body;
|
|
180
|
+
invariant(
|
|
181
|
+
manifest.schemaVersion === 1 && manifest.owner === 'idp-deploy' && /^sha256:[0-9a-f]{64}$/u.test(manifest.planDigest ?? '') &&
|
|
182
|
+
/^sha256:[0-9a-f]{64}$/u.test(digest ?? '') && sha256(stableJson(body)) === digest &&
|
|
183
|
+
sha256(stableJson({ ...planState, target: MANAGED_DIRECTORY })) === planDigest && Array.isArray(manifest.files),
|
|
184
|
+
'IDP_BINDING_MANAGED_DRIFT', '受管Binding Manifest合同或摘要无效,拒绝覆盖',
|
|
185
|
+
);
|
|
186
|
+
const actual = inventoryDirectory(target).filter((entry) => entry.path !== MANIFEST_FILE);
|
|
187
|
+
for (const entry of actual) {
|
|
188
|
+
invariant(!entry.unsupported, 'IDP_BINDING_MANAGED_DRIFT', '受管Binding目录包含链接或特殊文件,拒绝覆盖');
|
|
189
|
+
assertOwnedRegularFile(path.join(target, entry.path), `受管Binding ${entry.path}`);
|
|
190
|
+
}
|
|
191
|
+
const visitDirectories = (directory) => {
|
|
192
|
+
assertOwnedDirectory(directory, 'idp-deploy受管Binding子目录');
|
|
193
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
194
|
+
if (entry.isDirectory()) visitDirectories(path.join(directory, entry.name));
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
visitDirectories(target);
|
|
198
|
+
invariant(stableJson(actual) === stableJson(manifest.files), 'IDP_BINDING_MANAGED_DRIFT', '受管Binding文件被新增、删除或修改,拒绝覆盖');
|
|
199
|
+
return manifest;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error?.code === 'IDP_BINDING_MANAGED_DRIFT') throw error;
|
|
202
|
+
throw new IdpError('IDP_BINDING_MANAGED_DRIFT', `受管Binding目录不再符合上次发布状态,拒绝覆盖:${error.message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function buildRollbackToken({ profile, currentManifest, previousManifest, preimage }) {
|
|
207
|
+
const action = previousManifest ? 'restore' : 'remove';
|
|
208
|
+
const body = {
|
|
209
|
+
schemaVersion: 1,
|
|
210
|
+
target: MANAGED_DIRECTORY,
|
|
211
|
+
profile,
|
|
212
|
+
action,
|
|
213
|
+
expectedCurrentDigest: currentManifest.digest,
|
|
214
|
+
preimage: previousManifest ? { path: preimage, digest: previousManifest.digest } : null,
|
|
215
|
+
};
|
|
216
|
+
return { ...body, digest: sha256(stableJson(body)) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateRollbackToken(token) {
|
|
220
|
+
exactKeys(token, ROLLBACK_TOKEN_KEYS, 'IDP_BINDING_ROLLBACK_TOKEN_INVALID', 'Binding回滚令牌');
|
|
221
|
+
const { digest, ...body } = token;
|
|
222
|
+
invariant(
|
|
223
|
+
token.schemaVersion === 1 && token.target === MANAGED_DIRECTORY &&
|
|
224
|
+
['remove', 'restore'].includes(token.action) && DIGEST_PATTERN.test(token.expectedCurrentDigest ?? '') &&
|
|
225
|
+
DIGEST_PATTERN.test(digest ?? '') && sha256(stableJson(body)) === digest,
|
|
226
|
+
'IDP_BINDING_ROLLBACK_TOKEN_INVALID',
|
|
227
|
+
'Binding回滚令牌合同或摘要无效',
|
|
228
|
+
);
|
|
229
|
+
resolveProfile(token.profile);
|
|
230
|
+
if (token.action === 'remove') {
|
|
231
|
+
invariant(token.preimage === null, 'IDP_BINDING_ROLLBACK_TOKEN_INVALID', '首次创建Binding的回滚令牌不能声明Preimage');
|
|
232
|
+
} else {
|
|
233
|
+
exactKeys(token.preimage, ['digest', 'path'], 'IDP_BINDING_ROLLBACK_TOKEN_INVALID', 'Binding回滚Preimage');
|
|
234
|
+
invariant(
|
|
235
|
+
DIGEST_PATTERN.test(token.preimage.digest ?? '') &&
|
|
236
|
+
/^snapshots\/binding-preimages\/[A-Za-z0-9-]+$/u.test(token.preimage.path ?? ''),
|
|
237
|
+
'IDP_BINDING_ROLLBACK_TOKEN_INVALID',
|
|
238
|
+
'Binding回滚Preimage引用或摘要无效',
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return token;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function cloneManagedDirectory(source, target, expectedDigest) {
|
|
245
|
+
const before = validateManagedDirectory(source);
|
|
246
|
+
invariant(before.digest === expectedDigest, 'IDP_BINDING_ROLLBACK_PREIMAGE_INVALID', 'Binding Preimage摘要与回滚令牌不一致');
|
|
247
|
+
invariant(!fs.existsSync(target), 'IDP_BINDING_ROLLBACK_STAGING_COLLISION', 'Binding回滚暂存目录已存在');
|
|
248
|
+
fs.mkdirSync(target, { mode: 0o700 });
|
|
249
|
+
const copyDirectory = (sourceDirectory, targetDirectory) => {
|
|
250
|
+
for (const entry of fs.readdirSync(sourceDirectory, { withFileTypes: true })) {
|
|
251
|
+
const sourceEntry = path.join(sourceDirectory, entry.name);
|
|
252
|
+
const targetEntry = path.join(targetDirectory, entry.name);
|
|
253
|
+
if (entry.isDirectory()) {
|
|
254
|
+
assertOwnedDirectory(sourceEntry, 'Binding Preimage子目录');
|
|
255
|
+
fs.mkdirSync(targetEntry, { mode: 0o700 });
|
|
256
|
+
copyDirectory(sourceEntry, targetEntry);
|
|
257
|
+
} else {
|
|
258
|
+
assertOwnedRegularFile(sourceEntry, 'Binding Preimage文件');
|
|
259
|
+
createExclusiveFile(targetEntry, fs.readFileSync(sourceEntry), 0o600);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
fsyncDirectory(targetDirectory);
|
|
263
|
+
};
|
|
264
|
+
try {
|
|
265
|
+
copyDirectory(source, target);
|
|
266
|
+
const sourceAfter = validateManagedDirectory(source);
|
|
267
|
+
const copied = validateManagedDirectory(target);
|
|
268
|
+
invariant(
|
|
269
|
+
sourceAfter.digest === expectedDigest && copied.digest === expectedDigest,
|
|
270
|
+
'IDP_BINDING_ROLLBACK_PREIMAGE_INVALID',
|
|
271
|
+
'Binding Preimage在回滚候选创建期间发生变化',
|
|
272
|
+
);
|
|
273
|
+
return copied;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true });
|
|
276
|
+
if (error?.code?.startsWith('IDP_BINDING_ROLLBACK_')) throw error;
|
|
277
|
+
throw new IdpError('IDP_BINDING_ROLLBACK_PREIMAGE_INVALID', `Binding Preimage不安全或无法复制:${error.message}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function materializePlan({ staging, bindings, profile, env, report, activeState, verifyReceipt }) {
|
|
282
|
+
ensurePrivateDirectory(staging, 'Binding暂存目录');
|
|
283
|
+
for (const binding of bindings) {
|
|
284
|
+
const target = path.join(staging, binding.path);
|
|
285
|
+
ensurePrivateDirectory(path.dirname(target), 'Binding暂存子目录');
|
|
286
|
+
createExclusiveFile(target, renderServiceBinding(binding.document), 0o600);
|
|
287
|
+
}
|
|
288
|
+
const files = inventoryDirectory(staging);
|
|
289
|
+
const planFacts = {
|
|
290
|
+
schemaVersion: 1, owner: 'idp-deploy', target: MANAGED_DIRECTORY, profile,
|
|
291
|
+
environment: env.IDP_ENVIRONMENT, instanceId: env.IDP_INSTANCE_ID, configDigest: report.digest,
|
|
292
|
+
activeProfileDigest: activeState.digest, verifyReceiptDigest: verifyReceipt.digest, files,
|
|
293
|
+
};
|
|
294
|
+
const planDigest = sha256(stableJson(planFacts));
|
|
295
|
+
const stateBody = { ...planFacts, planDigest };
|
|
296
|
+
delete stateBody.target;
|
|
297
|
+
const manifest = { ...stateBody, digest: sha256(stableJson(stateBody)) };
|
|
298
|
+
createExclusiveFile(path.join(staging, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, 0o600);
|
|
299
|
+
fsyncDirectory(staging);
|
|
300
|
+
return { planDigest, manifest };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function publishPlan({ root, target, staging, expectedCurrent }) {
|
|
304
|
+
const preimages = resolveContained(root, 'snapshots/binding-preimages', 'Binding Preimage目录');
|
|
305
|
+
ensurePrivateDirectory(preimages, 'Binding Preimage目录');
|
|
306
|
+
let preimage = null;
|
|
307
|
+
let previousMoved = false;
|
|
308
|
+
let published = false;
|
|
309
|
+
try {
|
|
310
|
+
if (expectedCurrent) {
|
|
311
|
+
const current = validateManagedDirectory(target);
|
|
312
|
+
invariant(current.digest === expectedCurrent.digest, 'IDP_BINDING_MANAGED_DRIFT', '受管Binding在Plan创建后发生变化,拒绝覆盖');
|
|
313
|
+
const stamp = new Date().toISOString().replace(/[:.]/gu, '-');
|
|
314
|
+
preimage = path.join(preimages, `${stamp}-${expectedCurrent.planDigest.slice(7, 19)}-${crypto.randomUUID()}`);
|
|
315
|
+
fs.renameSync(target, preimage);
|
|
316
|
+
previousMoved = true;
|
|
317
|
+
fsyncDirectory(preimages);
|
|
318
|
+
} else {
|
|
319
|
+
invariant(!fs.existsSync(target), 'IDP_BINDING_MANAGED_DRIFT', '受管Binding目录在Plan创建后被其他进程创建,拒绝覆盖');
|
|
320
|
+
}
|
|
321
|
+
fs.renameSync(staging, target);
|
|
322
|
+
published = true;
|
|
323
|
+
fsyncDirectory(path.dirname(target));
|
|
324
|
+
} catch (error) {
|
|
325
|
+
let rollbackError;
|
|
326
|
+
try {
|
|
327
|
+
if (published && fs.existsSync(target)) {
|
|
328
|
+
invariant(!fs.existsSync(staging), 'IDP_BINDING_ROLLBACK_COLLISION', 'Binding回滚暂存路径已被占用');
|
|
329
|
+
fs.renameSync(target, staging);
|
|
330
|
+
published = false;
|
|
331
|
+
}
|
|
332
|
+
if (previousMoved && preimage && fs.existsSync(preimage)) fs.renameSync(preimage, target);
|
|
333
|
+
fsyncDirectory(path.dirname(target));
|
|
334
|
+
} catch (failure) { rollbackError = failure; }
|
|
335
|
+
if (rollbackError) {
|
|
336
|
+
throw new IdpError('IDP_BINDING_ROLLBACK_FAILED', `Binding发布失败且Preimage回滚未完成:${rollbackError.message}`, { originalError: error.message });
|
|
337
|
+
}
|
|
338
|
+
if (error?.code === 'IDP_BINDING_MANAGED_DRIFT') throw error;
|
|
339
|
+
throw new IdpError('IDP_BINDING_PUBLISH_FAILED', `Binding发布失败,原配置已恢复:${error.message}`);
|
|
340
|
+
} finally {
|
|
341
|
+
if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
|
|
342
|
+
}
|
|
343
|
+
return preimage ? path.relative(root, preimage) : null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function rollbackCurrent(target, expectedDigest) {
|
|
347
|
+
try {
|
|
348
|
+
invariant(fs.existsSync(target), 'IDP_BINDING_ROLLBACK_CAS_CONFLICT', '待回滚的受管Binding目录已不存在');
|
|
349
|
+
const current = validateManagedDirectory(target);
|
|
350
|
+
invariant(
|
|
351
|
+
current.digest === expectedDigest,
|
|
352
|
+
'IDP_BINDING_ROLLBACK_CAS_CONFLICT',
|
|
353
|
+
'受管Binding在同步完成后被用户或其他进程修改,拒绝回滚覆盖',
|
|
354
|
+
);
|
|
355
|
+
return current;
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (error?.code === 'IDP_BINDING_ROLLBACK_CAS_CONFLICT') throw error;
|
|
358
|
+
throw new IdpError(
|
|
359
|
+
'IDP_BINDING_ROLLBACK_CAS_CONFLICT',
|
|
360
|
+
`受管Binding在同步完成后不再符合CAS前置状态,拒绝回滚覆盖:${error.message}`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function rollbackPreimage(target, expectedDigest) {
|
|
366
|
+
try {
|
|
367
|
+
const preimage = validateManagedDirectory(target);
|
|
368
|
+
invariant(preimage.digest === expectedDigest, 'IDP_BINDING_ROLLBACK_PREIMAGE_INVALID', 'Binding Preimage摘要与回滚令牌不一致');
|
|
369
|
+
return preimage;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (error?.code === 'IDP_BINDING_ROLLBACK_PREIMAGE_INVALID') throw error;
|
|
372
|
+
throw new IdpError('IDP_BINDING_ROLLBACK_PREIMAGE_INVALID', `Binding Preimage不安全或已漂移,拒绝回滚:${error.message}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 使用syncManagedBindings返回的rollback令牌恢复受管Binding。
|
|
378
|
+
* Preimage只会被验证与复制,不会被移动、覆盖或删除。
|
|
379
|
+
*/
|
|
380
|
+
export function rollbackManagedBindingsCas({ configRoot, rollback, instanceLease }) {
|
|
381
|
+
const token = validateRollbackToken(rollback);
|
|
382
|
+
const preliminary = doctorConfig(configRoot, { requireConfigured: false, profile: token.profile });
|
|
383
|
+
return withInstanceLock(preliminary.root, preliminary.env, `bindings:rollback:${token.profile}`, () => {
|
|
384
|
+
const root = preliminary.root;
|
|
385
|
+
const bindingsRoot = resolveContained(root, 'bindings', 'Binding根目录');
|
|
386
|
+
assertOwnedDirectory(bindingsRoot, 'Binding根目录');
|
|
387
|
+
let target;
|
|
388
|
+
try { target = resolveContained(root, MANAGED_DIRECTORY, 'idp-deploy受管Binding目录'); }
|
|
389
|
+
catch (error) {
|
|
390
|
+
throw new IdpError('IDP_BINDING_ROLLBACK_CAS_CONFLICT', `待回滚的受管Binding路径不安全,拒绝覆盖:${error.message}`);
|
|
391
|
+
}
|
|
392
|
+
rollbackCurrent(target, token.expectedCurrentDigest);
|
|
393
|
+
|
|
394
|
+
let preimagePath = null;
|
|
395
|
+
let staging = null;
|
|
396
|
+
if (token.action === 'restore') {
|
|
397
|
+
try {
|
|
398
|
+
preimagePath = resolveContained(root, token.preimage.path, 'Binding回滚Preimage');
|
|
399
|
+
rollbackPreimage(preimagePath, token.preimage.digest);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (error?.code === 'IDP_BINDING_ROLLBACK_PREIMAGE_INVALID') throw error;
|
|
402
|
+
throw new IdpError('IDP_BINDING_ROLLBACK_PREIMAGE_INVALID', `Binding Preimage不安全或已漂移,拒绝回滚:${error.message}`);
|
|
403
|
+
}
|
|
404
|
+
staging = resolveContained(root, `imports/.idp-deploy-binding-rollback-${process.pid}-${crypto.randomUUID()}`, 'Binding回滚暂存目录');
|
|
405
|
+
cloneManagedDirectory(preimagePath, staging, token.preimage.digest);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const forwardRoot = resolveContained(root, 'snapshots/binding-rollback-forwards', 'Binding回滚前向快照目录');
|
|
409
|
+
ensurePrivateDirectory(forwardRoot, 'Binding回滚前向快照目录');
|
|
410
|
+
const stamp = new Date().toISOString().replace(/[:.]/gu, '-');
|
|
411
|
+
const forward = path.join(forwardRoot, `${stamp}-${token.expectedCurrentDigest.slice(7, 19)}-${crypto.randomUUID()}`);
|
|
412
|
+
let currentMoved = false;
|
|
413
|
+
let restoredPublished = false;
|
|
414
|
+
try {
|
|
415
|
+
fs.renameSync(target, forward);
|
|
416
|
+
currentMoved = true;
|
|
417
|
+
fsyncDirectory(bindingsRoot);
|
|
418
|
+
const moved = rollbackCurrent(forward, token.expectedCurrentDigest);
|
|
419
|
+
if (token.action === 'restore') {
|
|
420
|
+
rollbackPreimage(preimagePath, token.preimage.digest);
|
|
421
|
+
invariant(!fs.existsSync(target), 'IDP_BINDING_ROLLBACK_TARGET_COLLISION', 'Binding回滚目标被其他进程重新创建');
|
|
422
|
+
fs.renameSync(staging, target);
|
|
423
|
+
restoredPublished = true;
|
|
424
|
+
fsyncDirectory(bindingsRoot);
|
|
425
|
+
const restored = validateManagedDirectory(target);
|
|
426
|
+
invariant(restored.digest === token.preimage.digest, 'IDP_BINDING_ROLLBACK_RESTORE_INVALID', '恢复后的Binding摘要与Preimage不一致');
|
|
427
|
+
rollbackPreimage(preimagePath, token.preimage.digest);
|
|
428
|
+
}
|
|
429
|
+
fsyncDirectory(forwardRoot);
|
|
430
|
+
return {
|
|
431
|
+
schemaVersion: 1,
|
|
432
|
+
status: token.action === 'restore' ? 'restored' : 'removed',
|
|
433
|
+
profile: token.profile,
|
|
434
|
+
target: MANAGED_DIRECTORY,
|
|
435
|
+
restoredDigest: token.action === 'restore' ? token.preimage.digest : null,
|
|
436
|
+
preimage: token.preimage?.path ?? null,
|
|
437
|
+
forwardImage: path.relative(root, forward),
|
|
438
|
+
rolledBackDigest: moved.digest,
|
|
439
|
+
};
|
|
440
|
+
} catch (error) {
|
|
441
|
+
let recoveryError;
|
|
442
|
+
try {
|
|
443
|
+
if (restoredPublished && fs.existsSync(target)) {
|
|
444
|
+
const restored = validateManagedDirectory(target);
|
|
445
|
+
invariant(
|
|
446
|
+
restored.digest === token.preimage.digest,
|
|
447
|
+
'IDP_BINDING_ROLLBACK_RECOVERY_CONFLICT',
|
|
448
|
+
'回滚恢复目录已被并发修改,拒绝覆盖;前向快照仍被保留',
|
|
449
|
+
);
|
|
450
|
+
invariant(!fs.existsSync(staging), 'IDP_BINDING_ROLLBACK_STAGING_COLLISION', 'Binding回滚暂存路径被并发占用');
|
|
451
|
+
fs.renameSync(target, staging);
|
|
452
|
+
restoredPublished = false;
|
|
453
|
+
}
|
|
454
|
+
if (currentMoved && fs.existsSync(forward)) {
|
|
455
|
+
invariant(!fs.existsSync(target), 'IDP_BINDING_ROLLBACK_RECOVERY_CONFLICT', 'Binding目标被并发创建,拒绝覆盖;前向快照仍被保留');
|
|
456
|
+
fs.renameSync(forward, target);
|
|
457
|
+
currentMoved = false;
|
|
458
|
+
}
|
|
459
|
+
fsyncDirectory(bindingsRoot);
|
|
460
|
+
} catch (failure) { recoveryError = failure; }
|
|
461
|
+
if (recoveryError) {
|
|
462
|
+
throw new IdpError(
|
|
463
|
+
'IDP_BINDING_ROLLBACK_FAILED',
|
|
464
|
+
`Binding CAS回滚失败且无法安全恢复回滚前状态:${recoveryError.message}`,
|
|
465
|
+
{ originalError: error.message, forwardImage: path.relative(root, forward) },
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
if (error?.code?.startsWith('IDP_BINDING_ROLLBACK_')) throw error;
|
|
469
|
+
throw new IdpError('IDP_BINDING_ROLLBACK_FAILED', `Binding CAS回滚失败,已恢复回滚前状态:${error.message}`);
|
|
470
|
+
} finally {
|
|
471
|
+
if (staging && fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
|
|
472
|
+
}
|
|
473
|
+
}, { lease: instanceLease });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function syncManagedBindings({ configRoot, profile, instanceLease }) {
|
|
477
|
+
const preliminary = doctorConfig(configRoot, { requireConfigured: true, profile });
|
|
478
|
+
const { root } = preliminary;
|
|
479
|
+
const activeServices = resolveProfile(profile);
|
|
480
|
+
return withInstanceLock(root, preliminary.env, `bindings:sync:${profile}`, () => {
|
|
481
|
+
const { env, report } = doctorConfig(root, { requireConfigured: true, profile });
|
|
482
|
+
invariant(report.digest === preliminary.report.digest, 'IDP_BINDING_CONFIG_CHANGED_DURING_PLAN', 'Config Dir在Binding Plan创建期间发生变化,拒绝写入');
|
|
483
|
+
const activeState = readActiveProfileState(env);
|
|
484
|
+
invariant(activeState, 'IDP_BINDING_ACTIVE_PROFILE_MISSING', '尚无活动Profile运行事实;请先执行idpctl switch或up');
|
|
485
|
+
invariant(activeState.status === 'active' && activeState.profile === profile, 'IDP_BINDING_ACTIVE_PROFILE_MISMATCH', `活动Profile为${activeState.status}/${activeState.profile},不能生成${profile} Binding`);
|
|
486
|
+
invariant(activeState.configDigest === report.digest, 'IDP_BINDING_DEPLOYED_CONFIG_DRIFT', '当前Config Dir已变化但尚未重新部署,拒绝生成过期或虚假的Binding');
|
|
487
|
+
invariant(stableJson(activeState.services) === stableJson(resolveProfileServices(profile)), 'IDP_BINDING_ACTIVE_SERVICES_MISMATCH', '活动Profile服务集合与目标Profile不一致');
|
|
488
|
+
const verifyReceipt = readLatestVerifyEvidence(env, {
|
|
489
|
+
profile, configDigest: report.digest, renderDigest: activeState.renderDigest, activeUpdatedAt: activeState.updatedAt,
|
|
490
|
+
});
|
|
491
|
+
const bindings = buildManagedBindings({ env, activeServices });
|
|
492
|
+
invariant(bindings.length > 0, 'IDP_BINDING_NO_MANAGED_CAPABILITY', `${profile}没有当前部署控制面可以真实声明的Bench Binding`);
|
|
493
|
+
|
|
494
|
+
const bindingsRoot = resolveContained(root, 'bindings', 'Binding根目录');
|
|
495
|
+
assertOwnedDirectory(bindingsRoot, 'Binding根目录');
|
|
496
|
+
const target = resolveContained(root, MANAGED_DIRECTORY, 'idp-deploy受管Binding目录');
|
|
497
|
+
const expectedCurrent = fs.existsSync(target) ? validateManagedDirectory(target) : null;
|
|
498
|
+
const staging = resolveContained(root, `imports/.idp-deploy-bindings-${process.pid}-${crypto.randomUUID()}`, 'Binding暂存目录');
|
|
499
|
+
const { planDigest, manifest } = materializePlan({ staging, bindings, profile, env, report, activeState, verifyReceipt });
|
|
500
|
+
if (expectedCurrent?.planDigest === planDigest) {
|
|
501
|
+
const current = validateManagedDirectory(target);
|
|
502
|
+
invariant(current.digest === expectedCurrent.digest, 'IDP_BINDING_MANAGED_DRIFT', '受管Binding在Plan创建后发生变化,拒绝返回未变更状态');
|
|
503
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
504
|
+
return {
|
|
505
|
+
schemaVersion: 1, status: 'unchanged', profile, environment: env.IDP_ENVIRONMENT,
|
|
506
|
+
target: MANAGED_DIRECTORY, planDigest, preimage: null, rollback: null,
|
|
507
|
+
bindings: bindings.map(({ path: filePath, document }) => ({ path: filePath, id: document.metadata.id, profile: document.spec.profile })),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const preimage = publishPlan({ root, target, staging, expectedCurrent });
|
|
511
|
+
const rollback = buildRollbackToken({ profile, currentManifest: manifest, previousManifest: expectedCurrent, preimage });
|
|
512
|
+
return {
|
|
513
|
+
schemaVersion: 1, status: expectedCurrent ? 'updated' : 'created', profile, environment: env.IDP_ENVIRONMENT,
|
|
514
|
+
target: MANAGED_DIRECTORY, planDigest: manifest.planDigest, preimage, rollback,
|
|
515
|
+
bindings: bindings.map(({ path: filePath, document }) => ({ path: filePath, id: document.metadata.id, profile: document.spec.profile })),
|
|
516
|
+
};
|
|
517
|
+
}, { lease: instanceLease });
|
|
518
|
+
}
|