@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
package/src/images.mjs ADDED
@@ -0,0 +1,215 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { doctorConfig } from './config.mjs';
4
+ import { IdpError, invariant } from './errors.mjs';
5
+ import { stableJson } from './hash.mjs';
6
+ import { buildImageLock, IMAGE_KEYS, readImageLock, REQUIRED_IMAGE_PLATFORMS, validateImageLockDocument } from './image-lock.mjs';
7
+ import { withInstanceLock, writeReceipt } from './operations.mjs';
8
+ import { dockerOfficialMirrorRef } from './oci-mirror.mjs';
9
+ import { resolveProfile } from './profiles.mjs';
10
+ import { assertSafeRegularFile, atomicWrite, createExclusiveFile, parseEnv } from './security.mjs';
11
+ import { run } from './process.mjs';
12
+
13
+ export { readImageLock, validateImageLockDocument } from './image-lock.mjs';
14
+
15
+ const IMAGE_KEYS_BY_COMPONENT = Object.freeze({
16
+ postgresql: ['IDP_POSTGRES_IMAGE'],
17
+ edge: ['IDP_CADDY_IMAGE'],
18
+ registry: ['IDP_REGISTRY_IMAGE'],
19
+ tech: ['IDP_TECH_IMAGE'],
20
+ portal: ['IDP_PORTAL_IMAGE'],
21
+ flow: ['IDP_FLOW_IMAGE'],
22
+ smartgo: ['IDP_SMARTGO_IMAGE', 'IDP_SMARTGO_OBJECT_STORE_IMAGE', 'IDP_SMARTGO_OBJECT_STORE_CLIENT_IMAGE'],
23
+ });
24
+
25
+ export function imageKeysForProfile(profile) {
26
+ return [...new Set(resolveProfile(profile).flatMap((component) => IMAGE_KEYS_BY_COMPONENT[component] ?? []))];
27
+ }
28
+
29
+ export function resolveImage(ref, { platform = 'linux/arm64', runner = run } = {}) {
30
+ invariant(ref && ref !== 'UNCONFIGURED' && !/\s/u.test(ref), 'IDP_IMAGE_REF_INVALID', `镜像引用无效:${ref || '<empty>'}`);
31
+ const mirrorRef = dockerOfficialMirrorRef(ref);
32
+ let result;
33
+ try {
34
+ result = runner('docker', ['buildx', 'imagetools', 'inspect', mirrorRef ?? ref, '--format', '{{json .}}'], { capture: true });
35
+ } catch (error) {
36
+ if (!mirrorRef) throw error;
37
+ result = runner('docker', ['buildx', 'imagetools', 'inspect', ref, '--format', '{{json .}}'], { capture: true });
38
+ }
39
+ const output = Buffer.isBuffer(result.stdout) ? result.stdout.toString('utf8') : String(result.stdout ?? '');
40
+ let inspection;
41
+ try { inspection = JSON.parse(output); }
42
+ catch { invariant(false, 'IDP_IMAGE_INSPECT_INVALID', `无法解析镜像OCI检查结果:${ref}`); }
43
+ const digest = inspection?.manifest?.digest;
44
+ invariant(/^sha256:[0-9a-f]{64}$/u.test(digest ?? ''), 'IDP_IMAGE_DIGEST_UNRESOLVED', `无法解析镜像摘要:${ref}`);
45
+ const [wantedOs, wantedArchitecture] = platform.split('/');
46
+ const indexedPlatforms = Array.isArray(inspection?.manifest?.manifests)
47
+ ? inspection.manifest.manifests.map((manifest) => manifest?.platform).filter(Boolean)
48
+ : [];
49
+ const singleImagePlatform = inspection?.image ? [{ os: inspection.image.os, architecture: inspection.image.architecture, variant: inspection.image.variant }] : [];
50
+ const evidencePlatforms = [...indexedPlatforms, ...singleImagePlatform];
51
+ invariant(evidencePlatforms.some((entry) => entry.os === wantedOs && entry.architecture === wantedArchitecture), 'IDP_IMAGE_PLATFORM_MISSING', `镜像Manifest不包含${platform}:${ref}`);
52
+ return { digest, platform, evidence: indexedPlatforms.length > 0 ? 'oci-index-platform' : 'oci-image-config-platform' };
53
+ }
54
+
55
+ export function lockImages(configRoot, {
56
+ requiredPlatforms = REQUIRED_IMAGE_PLATFORMS,
57
+ profile = 'full',
58
+ overrides = {},
59
+ requireProfileImages = false,
60
+ instanceLease,
61
+ resolver = resolveImage,
62
+ writer = atomicWrite,
63
+ } = {}) {
64
+ const selectedKeys = new Set(imageKeysForProfile(profile));
65
+ const platforms = [...requiredPlatforms];
66
+ invariant(
67
+ stableJson(platforms) === stableJson([...new Set(platforms)].sort()) &&
68
+ platforms.length > 0 && platforms.every((candidate) => REQUIRED_IMAGE_PLATFORMS.includes(candidate)),
69
+ 'IDP_IMAGE_LOCK_REQUIRED_PLATFORMS_INVALID',
70
+ '镜像锁平台必须是按字典序排列且不重复的linux/amd64、linux/arm64集合',
71
+ );
72
+ invariant(overrides && typeof overrides === 'object' && !Array.isArray(overrides), 'IDP_IMAGE_OVERRIDE_INVALID', '镜像覆盖必须是键值对象');
73
+ for (const [key, ref] of Object.entries(overrides)) {
74
+ invariant(selectedKeys.has(key), 'IDP_IMAGE_OVERRIDE_OUTSIDE_PROFILE', `${key}不属于${profile} Profile,拒绝写入`);
75
+ invariant(typeof ref === 'string' && ref.length > 0 && ref !== 'UNCONFIGURED' && !/\s/u.test(ref), 'IDP_IMAGE_OVERRIDE_INVALID', `${key}镜像覆盖值无效`);
76
+ }
77
+ const preliminary = doctorConfig(configRoot, { requireConfigured: false, profile });
78
+ return withInstanceLock(preliminary.root, preliminary.env, 'images:lock', () => {
79
+ const { root, env } = doctorConfig(preliminary.root, { requireConfigured: false, profile });
80
+ invariant(env.IDP_RUNTIME_DIR === preliminary.env.IDP_RUNTIME_DIR, 'IDP_IMAGE_CONFIG_CONFLICT', '获取实例锁期间IDP_RUNTIME_DIR发生变化,禁止继续镜像锁定');
81
+ const envPath = path.join(root, '.env');
82
+ const lockPath = path.join(root, 'images.lock.json');
83
+ const before = fs.readFileSync(envPath, 'utf8');
84
+ const currentEnv = parseEnv(before, envPath);
85
+ const effectiveEnv = { ...currentEnv, ...overrides };
86
+ const locked = [];
87
+ const skipped = [];
88
+ let previousLock;
89
+ let previousLockText = null;
90
+ if (fs.existsSync(lockPath)) {
91
+ assertSafeRegularFile(lockPath, 0o600, { allowEmpty: false, role: '旧镜像锁' });
92
+ previousLockText = fs.readFileSync(lockPath, 'utf8');
93
+ previousLock = readImageLock(root, { allowLegacy: true, requiredPlatforms: ['linux/arm64'] }).document;
94
+ }
95
+ const previousEntries = new Map((previousLock?.images ?? []).map((entry) => [entry.key, previousLock.schemaVersion === 1
96
+ ? {
97
+ key: entry.key,
98
+ sourceRef: entry.sourceRef,
99
+ pinnedRef: entry.pinnedRef,
100
+ digest: entry.digest,
101
+ platforms: [{ platform: entry.platform, evidence: entry.evidence }],
102
+ }
103
+ : entry]));
104
+ let after = before;
105
+ for (const key of selectedKeys) {
106
+ const ref = effectiveEnv[key];
107
+ if (!ref || ref === 'UNCONFIGURED') {
108
+ skipped.push({ key, reason: 'unconfigured' });
109
+ continue;
110
+ }
111
+ const resolutions = platforms.map((platform) => {
112
+ const resolved = resolver(ref, { platform });
113
+ invariant(/^sha256:[0-9a-f]{64}$/u.test(resolved.digest), 'IDP_IMAGE_DIGEST_INVALID', `${key}在${platform}解析出了无效摘要`);
114
+ invariant(typeof resolved.evidence === 'string' && resolved.evidence.length > 0, 'IDP_IMAGE_EVIDENCE_INVALID', `${key}在${platform}缺少OCI平台证据`);
115
+ return { platform, digest: resolved.digest, evidence: resolved.evidence };
116
+ });
117
+ const [resolved] = resolutions;
118
+ for (const candidate of resolutions.slice(1)) {
119
+ invariant(candidate.digest === resolved.digest, 'IDP_IMAGE_PLATFORM_DIGEST_MISMATCH', `${key}在不同平台解析到不同OCI Index摘要,拒绝锁定可变tag`);
120
+ }
121
+ const repository = ref.replace(/@sha256:[0-9a-f]{64}$/u, '');
122
+ const pinned = `${repository}@${resolved.digest}`;
123
+ const previous = previousEntries.get(key);
124
+ const sourceRef = previous && [previous.sourceRef, previous.pinnedRef].includes(ref)
125
+ ? previous.sourceRef
126
+ : ref;
127
+ const keyPattern = new RegExp(`^${key}=.*$`, 'mu');
128
+ invariant(keyPattern.test(after), 'IDP_IMAGE_ENV_KEY_MISSING', `.env缺少${key},请先执行config init`);
129
+ after = after.replace(keyPattern, `${key}=${pinned}`);
130
+ locked.push({
131
+ key,
132
+ ref: pinned,
133
+ sourceRef,
134
+ pinnedRef: pinned,
135
+ digest: resolved.digest,
136
+ platforms: resolutions.map(({ platform, evidence }) => ({ platform, evidence })),
137
+ });
138
+ }
139
+ const preserved = [...previousEntries.values()].filter((entry) =>
140
+ !selectedKeys.has(entry.key) &&
141
+ currentEnv[entry.key] === entry.pinnedRef &&
142
+ platforms.every((platform) => entry.platforms.some((candidate) => candidate.platform === platform)));
143
+ const imageEntries = [...preserved, ...locked]
144
+ .map(({ ref: _ref, ...entry }) => entry)
145
+ .sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0);
146
+ const semanticPayload = { requiredPlatforms: platforms, images: imageEntries };
147
+ const previousSemanticPayload = previousLock?.schemaVersion === 2
148
+ ? { requiredPlatforms: previousLock.requiredPlatforms, images: previousLock.images }
149
+ : undefined;
150
+ const lockChanged = !previousLock || stableJson(semanticPayload) !== stableJson(previousSemanticPayload);
151
+ const lockDocument = lockChanged ? buildImageLock(imageEntries, { requiredPlatforms: platforms }) : previousLock;
152
+ const configuredSelectedKeys = [...selectedKeys].filter((key) => effectiveEnv[key] && effectiveEnv[key] !== 'UNCONFIGURED');
153
+ const requiredKeys = requireProfileImages ? [...selectedKeys] : configuredSelectedKeys;
154
+ validateImageLockDocument(lockDocument, { env: parseEnv(after, envPath), requiredPlatforms: platforms, requiredKeys });
155
+ const nextLockText = `${JSON.stringify(lockDocument, null, 2)}\n`;
156
+
157
+ const timestamp = new Date().toISOString().replace(/[:.]/gu, '-');
158
+ let preimage = null;
159
+ if (after !== before) {
160
+ preimage = path.join(root, 'snapshots', `${timestamp}-images.env.preimage`);
161
+ createExclusiveFile(preimage, before, 0o600);
162
+ }
163
+ let lockPreimage = null;
164
+ if (lockChanged) {
165
+ if (previousLock) {
166
+ lockPreimage = path.join(root, 'snapshots', `${timestamp}-images.lock.json.preimage`);
167
+ createExclusiveFile(lockPreimage, previousLockText, 0o600);
168
+ }
169
+ }
170
+ const currentLockText = () => fs.existsSync(lockPath) ? fs.readFileSync(lockPath, 'utf8') : null;
171
+ invariant(fs.readFileSync(envPath, 'utf8') === before, 'IDP_IMAGE_ENV_CONFLICT', '.env在镜像解析期间被修改,禁止覆盖用户新内容');
172
+ invariant(currentLockText() === previousLockText, 'IDP_IMAGE_LOCK_CONFLICT', 'images.lock.json在镜像解析期间被修改,禁止覆盖用户新内容');
173
+ try {
174
+ if (after !== before) writer(envPath, after, 0o600);
175
+ if (lockChanged) writer(lockPath, nextLockText, 0o600);
176
+ const validated = doctorConfig(root, { requireConfigured: false, profile });
177
+ invariant(validated.env.IDP_RUNTIME_DIR === env.IDP_RUNTIME_DIR, 'IDP_IMAGE_CONFIG_CONFLICT', '镜像解析期间IDP_RUNTIME_DIR发生变化,已拒绝发布');
178
+ const receipt = writeReceipt(validated.env, 'images-lock', {
179
+ requiredPlatforms: platforms,
180
+ profile,
181
+ lockDigest: lockDocument.digest,
182
+ locked: locked.map(({ key, pinnedRef }) => ({ key, pinnedRef })),
183
+ skipped,
184
+ envPreimageCreated: Boolean(preimage),
185
+ lockPreimageCreated: Boolean(lockPreimage),
186
+ });
187
+ return {
188
+ locked,
189
+ skipped,
190
+ preimage,
191
+ lockPreimage,
192
+ lockPath,
193
+ lockDigest: lockDocument.digest,
194
+ requiredPlatforms: platforms,
195
+ profile,
196
+ receipt: receipt.receipt,
197
+ report: validated.report,
198
+ };
199
+ } catch (error) {
200
+ try {
201
+ const observedLock = currentLockText();
202
+ if (observedLock === nextLockText && lockChanged) {
203
+ if (previousLockText === null) fs.unlinkSync(lockPath);
204
+ else writer(lockPath, previousLockText, 0o600);
205
+ } else invariant(observedLock === previousLockText, 'IDP_IMAGE_LOCK_RECOVERY_REQUIRED', '镜像锁失败后发现并发修改,禁止自动覆盖');
206
+ const observedEnv = fs.readFileSync(envPath, 'utf8');
207
+ if (observedEnv === after && after !== before) writer(envPath, before, 0o600);
208
+ else invariant(observedEnv === before, 'IDP_IMAGE_LOCK_RECOVERY_REQUIRED', '镜像锁失败后发现.env并发修改,禁止自动覆盖');
209
+ } catch (recoveryError) {
210
+ throw new IdpError('IDP_IMAGE_LOCK_RECOVERY_REQUIRED', `镜像锁写入失败且自动回滚未完成:${recoveryError.message}`, { originalError: error.message });
211
+ }
212
+ throw error;
213
+ }
214
+ }, { lease: instanceLease });
215
+ }
@@ -0,0 +1,58 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { IdpError } from './errors.mjs';
5
+ import { stableJson } from './hash.mjs';
6
+
7
+ const decisions = new Set(['accepted', 'replaced', 'selectively-received', 'deferred']);
8
+ const activeComponents = ['postgresql', 'registry', 'tech', 'flow', 'portal', 'edge'];
9
+
10
+ function invariant(condition, code, message) {
11
+ if (!condition) throw new IdpError(code, message);
12
+ }
13
+
14
+ function repositoryPath(repositoryRoot, relative, label) {
15
+ invariant(typeof relative === 'string' && relative && !path.isAbsolute(relative) && !relative.split(/[\\/]/u).includes('..'), 'IDP_LIFECYCLE_PATH_INVALID', `${label}必须是仓库内相对路径:${relative}`);
16
+ const target = path.resolve(repositoryRoot, relative);
17
+ const root = path.resolve(repositoryRoot);
18
+ invariant(target === root || target.startsWith(`${root}${path.sep}`), 'IDP_LIFECYCLE_PATH_INVALID', `${label}越出仓库:${relative}`);
19
+ invariant(fs.existsSync(target), 'IDP_LIFECYCLE_TARGET_MISSING', `${label}不存在:${relative}`);
20
+ return target;
21
+ }
22
+
23
+ export function verifyAssetLifecycle(repositoryRoot) {
24
+ const manifestPath = repositoryPath(repositoryRoot, 'governance/asset-lifecycle.v1.json', '生命周期清单');
25
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
26
+ invariant(manifest.schemaVersion === 'idp-deploy.asset-lifecycle/v1' && manifest.planId === 'idp-deploy.bench-deployment-ownership-reception@1' && manifest.version === 1 && manifest.immutable === true, 'IDP_LIFECYCLE_MANIFEST_INVALID', '生命周期清单批准事实无效');
27
+ invariant(stableJson(manifest.activeComponents) === stableJson(activeComponents), 'IDP_LIFECYCLE_COMPONENTS_INVALID', '活动组件集合或顺序与当前最小 IDP 不一致');
28
+ repositoryPath(repositoryRoot, manifest.archiveRoot, '归档目录');
29
+ invariant(Array.isArray(manifest.receipts) && manifest.receipts.length === 6, 'IDP_LIFECYCLE_RECEIPTS_INVALID', 'Bench 部署接收回执必须完整覆盖六类来源资产');
30
+ const identities = [];
31
+ for (const receipt of manifest.receipts) {
32
+ invariant(receipt?.sourceProject === 'bench' && typeof receipt.sourceAsset === 'string' && receipt.sourceAsset, 'IDP_LIFECYCLE_RECEIPT_INVALID', '接收回执来源身份无效');
33
+ invariant(!identities.includes(receipt.sourceAsset), 'IDP_LIFECYCLE_RECEIPT_DUPLICATE', `接收回执重复:${receipt.sourceAsset}`);
34
+ identities.push(receipt.sourceAsset);
35
+ invariant(/^sha256:[0-9a-f]{64}$/u.test(receipt.sourceEvidence ?? '') && decisions.has(receipt.decision), 'IDP_LIFECYCLE_RECEIPT_INVALID', `接收回执摘要或决策无效:${receipt.sourceAsset}`);
36
+ invariant(typeof receipt.targetCapability === 'string' && receipt.targetCapability && Array.isArray(receipt.targetPaths) && Array.isArray(receipt.evidence) && receipt.evidence.length > 0 && Array.isArray(receipt.remainingGates), 'IDP_LIFECYCLE_RECEIPT_INVALID', `接收回执字段不完整:${receipt.sourceAsset}`);
37
+ if (receipt.decision === 'deferred') invariant(receipt.targetPaths.length === 0 && receipt.remainingGates.length > 0, 'IDP_LIFECYCLE_DEFERRED_INVALID', `延后资产不能伪造目标实现:${receipt.sourceAsset}`);
38
+ else invariant(receipt.targetPaths.length > 0, 'IDP_LIFECYCLE_TARGET_MISSING', `已接收资产缺少目标实现:${receipt.sourceAsset}`);
39
+ for (const target of receipt.targetPaths) repositoryPath(repositoryRoot, target, `${receipt.sourceAsset}目标`);
40
+ for (const evidence of receipt.evidence) repositoryPath(repositoryRoot, evidence, `${receipt.sourceAsset}证据`);
41
+ }
42
+ const expectedSources = ['packages/ops', 'scripts/ops-agent.mjs', 'docker-compose.yml', 'local', 'gitops', 'infra/terraform'];
43
+ invariant(stableJson(identities) === stableJson(expectedSources), 'IDP_LIFECYCLE_RECEIPTS_INVALID', '接收回执未按固定清单覆盖 Bench 部署资产');
44
+ invariant(manifest.policy?.runtimeCrossRepositorySourceReads === false && manifest.policy?.archiveIncludedInRuntime === false && manifest.policy?.secretsOnlyFromExternalConfigDir === true && manifest.policy?.unapprovedRemoteResources === false && manifest.policy?.portalWriteProxy === false, 'IDP_LIFECYCLE_POLICY_INVALID', '生命周期安全策略无效');
45
+ const { digest, ...facts } = manifest;
46
+ const expectedDigest = `sha256:${crypto.createHash('sha256').update(stableJson(facts)).digest('hex')}`;
47
+ invariant(digest === expectedDigest, 'IDP_LIFECYCLE_DIGEST_MISMATCH', `生命周期清单摘要不匹配,期望${expectedDigest}`);
48
+ return {
49
+ schemaVersion: 'idp-deploy.lifecycle-check/v1',
50
+ status: 'valid',
51
+ planId: manifest.planId,
52
+ activeComponents,
53
+ receiptCount: manifest.receipts.length,
54
+ acceptedCount: manifest.receipts.filter(({ decision }) => decision !== 'deferred').length,
55
+ deferredCount: manifest.receipts.filter(({ decision }) => decision === 'deferred').length,
56
+ digest,
57
+ };
58
+ }
@@ -0,0 +1,354 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { IdpError, invariant } from './errors.mjs';
4
+ import { hashFile, sha256, stableJson } from './hash.mjs';
5
+ import { run } from './process.mjs';
6
+ import { assertOutsideRepositories, assertSafeRegularFile, atomicWrite, canonicalPlannedDirectory, parseEnv, resolveContained, secureDirectoryRoot } from './security.mjs';
7
+
8
+ const APPLICATION_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/u;
9
+ const ENV_NAME = /^[A-Z][A-Z0-9_]{0,127}$/u;
10
+ const SENSITIVE = /(?:^|_)(?:PASSWORD|PASSWD|TOKEN|SECRET|PRIVATE_KEY|ACCESS_KEY|API_KEY|DATABASE_URL|CONNECTION_STRING|DSN|AUTHORIZATION|CREDENTIALS?)(?:_|$)/u;
11
+ const PLAN_SCHEMA = 'idp.local-source-deployment-plan/v1';
12
+
13
+ function readJson(candidate, role) {
14
+ const stat = fs.lstatSync(candidate, { throwIfNoEntry: false });
15
+ invariant(stat?.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && stat.size > 1 && stat.size <= 4 * 1024 * 1024, 'IDP_LOCAL_INPUT_INVALID', `${role}必须是1 byte到4 MiB的普通非链接文件:${candidate}`);
16
+ try { return JSON.parse(fs.readFileSync(candidate, 'utf8')); }
17
+ catch (error) { throw new IdpError('IDP_LOCAL_INPUT_JSON_INVALID', `${role}不是有效JSON`, { cause: error.message }); }
18
+ }
19
+
20
+ function readProject(projectRoot) {
21
+ invariant(path.isAbsolute(projectRoot ?? ''), 'IDP_LOCAL_PROJECT_NOT_ABSOLUTE', '--project必须是绝对路径');
22
+ const root = fs.realpathSync.native(projectRoot);
23
+ invariant(fs.lstatSync(root).isDirectory(), 'IDP_LOCAL_PROJECT_INVALID', '项目路径必须是目录');
24
+ const manifestFile = path.join(root, 'project-capabilities.json');
25
+ const contractFile = path.join(root, 'config', 'application-config.json');
26
+ const manifest = readJson(manifestFile, 'Project Manifest');
27
+ const contract = validateApplicationConfigContract(readJson(contractFile, 'ApplicationConfigContract'), manifest);
28
+ return { root, manifestFile, contractFile, manifest, contract };
29
+ }
30
+
31
+ function projectSourceDigest(root) {
32
+ const ignored = new Set(['.git', '.dyyto', '.next', '.turbo', 'coverage', 'dist', 'node_modules']);
33
+ const entries = [];
34
+ function visit(directory) {
35
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
36
+ if (ignored.has(entry.name)) continue;
37
+ const absolute = path.join(directory, entry.name);
38
+ const relative = path.relative(root, absolute);
39
+ const stat = fs.lstatSync(absolute);
40
+ invariant(!stat.isSymbolicLink(), 'IDP_LOCAL_SOURCE_SYMLINK_FORBIDDEN', `本地构建源不允许符号链接:${relative}`);
41
+ if (entry.isDirectory()) visit(absolute);
42
+ else if (entry.isFile()) entries.push({ path: relative, mode: stat.mode & 0o111, size: stat.size, digest: hashFile(absolute) });
43
+ else throw new IdpError('IDP_LOCAL_SOURCE_ENTRY_UNSUPPORTED', `本地构建源包含不支持的文件类型:${relative}`);
44
+ }
45
+ }
46
+ visit(root);
47
+ return sha256(stableJson(entries));
48
+ }
49
+
50
+ function validateApplicationConfigContract(contract, manifest) {
51
+ invariant(contract?.schemaVersion === 1 && contract.kind === 'ApplicationConfigContract', 'IDP_APPLICATION_CONFIG_INVALID', 'ApplicationConfigContract版本无效');
52
+ const id = contract.application?.id;
53
+ invariant(APPLICATION_ID.test(id ?? ''), 'IDP_APPLICATION_CONFIG_INVALID', 'Application identity无效');
54
+ invariant(contract.configDirectory?.rootEnvironmentVariable === 'IDP_CONFIG_DIR' && contract.configDirectory?.relativeDirectory === `applications/${id}` && contract.configDirectory?.environmentFile === '.env' && contract.configDirectory?.materializationOwner === 'external-deployment-control-plane', 'IDP_APPLICATION_CONFIG_INVALID', 'Application Config Dir合同无效');
55
+ invariant(manifest?.profile?.parameters?.name === id && manifest?.profile?.id === contract.application?.profile, 'IDP_APPLICATION_CONFIG_IDENTITY_MISMATCH', 'ApplicationConfigContract与Project Manifest身份不一致');
56
+ invariant(Array.isArray(contract.bindings) && contract.bindings.length <= 128, 'IDP_APPLICATION_CONFIG_INVALID', 'Application Binding列表无效');
57
+ const names = new Set(); const files = new Set();
58
+ for (const binding of contract.bindings) {
59
+ invariant(ENV_NAME.test(binding?.name ?? '') && !names.has(binding.name), 'IDP_APPLICATION_CONFIG_INVALID', `Application Binding名称无效或重复:${binding?.name ?? ''}`);
60
+ names.add(binding.name);
61
+ invariant(typeof binding.required === 'boolean' && ['value', 'secret-file', 'certificate-file'].includes(binding.kind), 'IDP_APPLICATION_CONFIG_INVALID', `Application Binding无效:${binding.name}`);
62
+ if (binding.kind === 'value') {
63
+ invariant(!SENSITIVE.test(binding.name), 'IDP_APPLICATION_SECRET_LITERAL_FORBIDDEN', `敏感配置${binding.name}必须使用文件Binding`);
64
+ invariant(binding.default === undefined || typeof binding.default === 'string', 'IDP_APPLICATION_CONFIG_INVALID', `${binding.name}默认值无效`);
65
+ continue;
66
+ }
67
+ invariant(binding.name.endsWith('_FILE') && typeof binding.file === 'string', 'IDP_APPLICATION_CONFIG_INVALID', `${binding.name}必须是*_FILE Binding`);
68
+ const prefix = binding.kind === 'secret-file' ? 'secrets/' : 'certs/';
69
+ invariant(binding.file.startsWith(prefix) && !path.isAbsolute(binding.file) && !binding.file.split('/').some((part) => ['', '.', '..'].includes(part)) && !files.has(binding.file), 'IDP_APPLICATION_CONFIG_INVALID', `${binding.name}文件路径无效`);
70
+ files.add(binding.file);
71
+ }
72
+ return contract;
73
+ }
74
+
75
+ function applicationDirectory(configRoot, id, { create = false, applicationConfigRoot } = {}) {
76
+ if (applicationConfigRoot) {
77
+ const app = secureDirectoryRoot(applicationConfigRoot, 'APP_CONFIG_DIR', { create });
78
+ assertOutsideRepositories(app, 'APP_CONFIG_DIR');
79
+ return { root: app, app };
80
+ }
81
+ const root = secureDirectoryRoot(configRoot, 'IDP_CONFIG_DIR', { create });
82
+ assertOutsideRepositories(root, 'IDP_CONFIG_DIR');
83
+ const applications = resolveContained(root, 'applications', '应用配置目录');
84
+ if (create) secureDirectoryRoot(applications, '应用配置目录', { create: true });
85
+ const app = resolveContained(root, `applications/${id}`, '应用配置目录');
86
+ if (create) secureDirectoryRoot(app, '应用配置目录', { create: true });
87
+ return { root, app };
88
+ }
89
+
90
+ function savePreimage(root, id, name, bytes) {
91
+ const directory = resolveContained(root, `snapshots/applications/${id}`, '应用配置Preimage目录');
92
+ secureDirectoryRoot(directory, '应用配置Preimage目录', { create: true });
93
+ const digest = sha256(bytes);
94
+ const candidate = path.join(directory, `${Date.now()}-${name}-${digest.slice(7, 19)}`);
95
+ fs.writeFileSync(candidate, bytes, { mode: 0o600, flag: 'wx' });
96
+ return { path: candidate, digest };
97
+ }
98
+
99
+ export function initializeApplicationConfig({ configRoot, projectRoot, applicationConfigRoot }) {
100
+ const project = readProject(projectRoot);
101
+ const { root, app } = applicationDirectory(configRoot, project.contract.application.id, { create: true, applicationConfigRoot });
102
+ for (const name of ['secrets', 'certs']) secureDirectoryRoot(path.join(app, name), `应用${name}目录`, { create: true });
103
+ const envFile = path.join(app, '.env');
104
+ const before = fs.existsSync(envFile) ? fs.readFileSync(envFile) : Buffer.from('');
105
+ if (fs.existsSync(envFile)) assertSafeRegularFile(envFile, 0o600, { role: '应用.env' });
106
+ const existing = parseEnv(before.toString('utf8'), envFile);
107
+ const additions = [];
108
+ const createdFiles = [];
109
+ for (const binding of project.contract.bindings) {
110
+ if (!(binding.name in existing)) additions.push(`${binding.name}=${binding.kind === 'value' ? (binding.default ?? '') : binding.file}`);
111
+ if (binding.kind !== 'value') {
112
+ const target = resolveContained(app, binding.file, `${binding.name}文件`);
113
+ secureDirectoryRoot(path.dirname(target), `${binding.name}父目录`, { create: true });
114
+ if (!fs.existsSync(target)) { fs.writeFileSync(target, '', { mode: 0o600, flag: 'wx' }); createdFiles.push(target); }
115
+ else assertSafeRegularFile(target, 0o600, { role: binding.name });
116
+ }
117
+ }
118
+ let preimage;
119
+ if (!fs.existsSync(envFile)) atomicWrite(envFile, additions.length ? `${additions.join('\n')}\n` : '', 0o600);
120
+ else if (additions.length) {
121
+ preimage = savePreimage(root, project.contract.application.id, 'env', before);
122
+ atomicWrite(envFile, `${before.toString('utf8').trimEnd()}\n# 由app config-init补齐;已有值未覆盖。\n${additions.join('\n')}\n`, 0o600);
123
+ }
124
+ return { schemaVersion: 'idp.application-config-materialization/v1', status: 'ready', application: project.contract.application.id, directory: app, environmentFile: envFile, createdFiles, additions: additions.map((line) => line.slice(0, line.indexOf('='))), ...(preimage ? { preimage } : {}) };
125
+ }
126
+
127
+ function inspectApplicationConfig(configRoot, contract, applicationConfigRoot) {
128
+ const { app } = applicationDirectory(configRoot, contract.application.id, { applicationConfigRoot });
129
+ const envFile = path.join(app, '.env');
130
+ assertSafeRegularFile(envFile, 0o600, { allowEmpty: contract.bindings.length === 0, role: '应用.env' });
131
+ const env = parseEnv(fs.readFileSync(envFile, 'utf8'), envFile);
132
+ const files = [];
133
+ for (const binding of contract.bindings) {
134
+ invariant(binding.name in env, 'IDP_APPLICATION_CONFIG_MISSING', `应用.env缺少${binding.name}`);
135
+ if (binding.kind === 'value') {
136
+ if (binding.required) invariant(env[binding.name] !== '', 'IDP_APPLICATION_CONFIG_REQUIRED', `${binding.name}尚未配置`);
137
+ continue;
138
+ }
139
+ invariant(env[binding.name] === binding.file, 'IDP_APPLICATION_CONFIG_REFERENCE_CHANGED', `${binding.name}必须保持合同声明的相对文件引用`);
140
+ const candidate = resolveContained(app, binding.file, binding.name);
141
+ assertSafeRegularFile(candidate, 0o600, { allowEmpty: !binding.required, role: binding.name });
142
+ files.push({ name: binding.name, path: binding.file, digest: hashFile(candidate), required: binding.required });
143
+ }
144
+ return { app, envFile, envDigest: hashFile(envFile), fileBindings: files };
145
+ }
146
+
147
+ function composeArgs(plan, tail) {
148
+ return ['compose', '--project-name', plan.composeProject, '--env-file', plan.config.environmentFile, '--file', plan.compose.path, ...tail];
149
+ }
150
+
151
+ function executeCompose(runner, args, cwd, capture = true) {
152
+ return runner('docker', args, { cwd, capture, maxBuffer: 16 * 1024 * 1024 });
153
+ }
154
+
155
+ function normalizedCompose({ projectRoot, composeFile, envFile, composeProject, runner }) {
156
+ const result = executeCompose(runner, ['compose', '--project-name', composeProject, '--env-file', envFile, '--file', composeFile, 'config', '--format', 'json'], projectRoot, true);
157
+ let document;
158
+ try { document = JSON.parse(result.stdout); }
159
+ catch (error) { throw new IdpError('IDP_LOCAL_COMPOSE_JSON_INVALID', 'docker compose未返回有效JSON', { cause: error.message }); }
160
+ validateCompose(document, projectRoot);
161
+ return { document, bytes: `${stableJson(document)}\n` };
162
+ }
163
+
164
+ function validateCompose(document, projectRoot) {
165
+ const services = document?.services;
166
+ invariant(services && typeof services === 'object' && Object.keys(services).length > 0, 'IDP_LOCAL_COMPOSE_INVALID', 'Compose必须至少声明一个服务');
167
+ let healthchecks = 0;
168
+ for (const [name, service] of Object.entries(services)) {
169
+ invariant(!service.privileged && service.network_mode !== 'host' && service.container_name === undefined, 'IDP_LOCAL_COMPOSE_UNSAFE', `服务${name}包含privileged、host network或container_name`);
170
+ invariant(!Array.isArray(service.cap_add) || service.cap_add.length === 0, 'IDP_LOCAL_COMPOSE_UNSAFE', `服务${name}不允许cap_add`);
171
+ invariant(!Array.isArray(service.devices) || service.devices.length === 0, 'IDP_LOCAL_COMPOSE_UNSAFE', `服务${name}不允许设备直通`);
172
+ invariant(!Array.isArray(service.secrets) || service.secrets.length === 0, 'IDP_LOCAL_COMPOSE_SECRET_LITERAL', `服务${name}不允许从项目Compose投影Secret;请使用ApplicationConfigContract的*_FILE`);
173
+ if (service.build) {
174
+ const context = typeof service.build === 'string' ? service.build : service.build.context;
175
+ invariant(typeof context === 'string', 'IDP_LOCAL_COMPOSE_BUILD_INVALID', `服务${name}构建上下文无效`);
176
+ const absoluteContext = path.resolve(projectRoot, context);
177
+ const contextRelative = path.relative(projectRoot, absoluteContext);
178
+ invariant(contextRelative === '' || (!contextRelative.startsWith('..') && !path.isAbsolute(contextRelative)), 'IDP_LOCAL_COMPOSE_BUILD_OUTSIDE_PROJECT', `服务${name}构建上下文必须位于项目内`);
179
+ }
180
+ if (service.healthcheck && !service.healthcheck.disable) healthchecks += 1;
181
+ for (const [key, value] of Object.entries(service.environment ?? {})) {
182
+ invariant(!(SENSITIVE.test(key) && value !== null && value !== ''), 'IDP_LOCAL_COMPOSE_SECRET_LITERAL', `服务${name}不得内联敏感环境变量${key}`);
183
+ }
184
+ for (const volume of service.volumes ?? []) {
185
+ const source = typeof volume === 'string' ? volume.split(':')[0] : volume?.source;
186
+ invariant(source !== '/var/run/docker.sock', 'IDP_LOCAL_COMPOSE_UNSAFE', `服务${name}不允许挂载Docker Socket`);
187
+ if (typeof source === 'string' && path.isAbsolute(source)) {
188
+ const relative = path.relative(projectRoot, path.resolve(source));
189
+ invariant(relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative), 'IDP_LOCAL_COMPOSE_HOST_MOUNT_FORBIDDEN', `服务${name}的绝对宿主挂载必须位于项目内`);
190
+ }
191
+ }
192
+ for (const port of service.ports ?? []) {
193
+ const hostIp = typeof port === 'object' ? port.host_ip : undefined;
194
+ invariant(hostIp === '127.0.0.1' || hostIp === '::1', 'IDP_LOCAL_COMPOSE_PORT_EXPOSED', `服务${name}发布端口必须显式绑定回环地址`);
195
+ }
196
+ }
197
+ invariant(healthchecks > 0, 'IDP_LOCAL_COMPOSE_HEALTHCHECK_REQUIRED', 'Compose至少一个服务必须声明healthcheck');
198
+ }
199
+
200
+ function planIdentity(core) { return sha256(stableJson(core)); }
201
+
202
+ export function createLocalSourcePlan({ configRoot, applicationConfigRoot, projectRoot, composeFile, output, runner = run, now = new Date().toISOString() }) {
203
+ const project = readProject(projectRoot);
204
+ invariant(path.isAbsolute(composeFile ?? ''), 'IDP_LOCAL_COMPOSE_NOT_ABSOLUTE', '--compose-file必须是绝对路径');
205
+ const composePath = fs.realpathSync.native(composeFile);
206
+ const relativeCompose = path.relative(project.root, composePath);
207
+ invariant(relativeCompose && !relativeCompose.startsWith('..') && !path.isAbsolute(relativeCompose), 'IDP_LOCAL_COMPOSE_OUTSIDE_PROJECT', 'Compose文件必须位于项目内');
208
+ assertSafeRegularFile(composePath, 0o644, { allowEmpty: false, role: '本地Compose文件' });
209
+ const config = inspectApplicationConfig(configRoot, project.contract, applicationConfigRoot);
210
+ const composeProject = `idp-${project.contract.application.id}-local`.replace(/[^a-z0-9_-]/gu, '-').slice(0, 63);
211
+ const normalized = normalizedCompose({ projectRoot: project.root, composeFile: composePath, envFile: config.envFile, composeProject, runner });
212
+ const core = {
213
+ schemaVersion: PLAN_SCHEMA, mode: 'local-source', promotable: false, application: project.contract.application.id,
214
+ project: { root: project.root, sourceDigest: projectSourceDigest(project.root), manifest: { path: project.manifestFile, digest: hashFile(project.manifestFile) }, applicationConfigContract: { path: project.contractFile, digest: hashFile(project.contractFile) } },
215
+ config: { directory: config.app, environmentFile: config.envFile, environmentDigest: config.envDigest, fileBindings: config.fileBindings },
216
+ compose: { path: composePath, sourceDigest: hashFile(composePath), normalizedDigest: sha256(normalized.bytes) }, composeProject, createdAt: now,
217
+ };
218
+ const plan = { ...core, planId: planIdentity(core) };
219
+ const { root } = applicationDirectory(configRoot, project.contract.application.id, { applicationConfigRoot });
220
+ invariant(path.isAbsolute(output ?? ''), 'IDP_LOCAL_PLAN_OUTPUT_NOT_ABSOLUTE', '--output必须是绝对路径');
221
+ const plans = resolveContained(root, 'plans/local-applications', '本地应用Plan目录');
222
+ secureDirectoryRoot(plans, '本地应用Plan目录', { create: true });
223
+ const target = canonicalPlannedDirectory(output, 'Local DeploymentPlan输出');
224
+ const relative = path.relative(plans, target);
225
+ invariant(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'IDP_LOCAL_PLAN_OUTPUT_OUTSIDE_CONFIG', 'Plan必须写入IDP_CONFIG_DIR/plans/local-applications');
226
+ invariant(!fs.existsSync(target), 'IDP_LOCAL_PLAN_EXISTS', 'Plan输出已存在,不允许覆盖');
227
+ atomicWrite(target, `${JSON.stringify(plan, null, 2)}\n`, 0o600);
228
+ return plan;
229
+ }
230
+
231
+ function readPlan(configRoot, planFile, applicationConfigRoot) {
232
+ invariant(path.isAbsolute(planFile ?? ''), 'IDP_LOCAL_PLAN_NOT_ABSOLUTE', '--plan必须是绝对路径');
233
+ const { root } = applicationDirectory(configRoot, 'placeholder', { applicationConfigRoot });
234
+ const plans = resolveContained(root, 'plans/local-applications', '本地应用Plan目录');
235
+ const candidate = fs.realpathSync.native(planFile);
236
+ const relative = path.relative(plans, candidate);
237
+ invariant(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'IDP_LOCAL_PLAN_OUTSIDE_CONFIG', 'Plan必须位于IDP_CONFIG_DIR/plans/local-applications');
238
+ assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: 'Local DeploymentPlan' });
239
+ const plan = readJson(candidate, 'Local DeploymentPlan');
240
+ invariant(plan.schemaVersion === PLAN_SCHEMA && plan.mode === 'local-source' && plan.promotable === false, 'IDP_LOCAL_PLAN_INVALID', 'Local DeploymentPlan版本无效');
241
+ const { planId, ...core } = plan;
242
+ invariant(planIdentity(core) === planId, 'IDP_LOCAL_PLAN_DIGEST_INVALID', 'Local DeploymentPlan摘要无效');
243
+ return { plan, candidate, root };
244
+ }
245
+
246
+ function revalidate(plan, runner) {
247
+ invariant(projectSourceDigest(plan.project.root) === plan.project.sourceDigest, 'IDP_LOCAL_SOURCE_CHANGED', '项目构建源已变化,请重新生成Plan');
248
+ invariant(hashFile(plan.project.manifest.path) === plan.project.manifest.digest && hashFile(plan.project.applicationConfigContract.path) === plan.project.applicationConfigContract.digest && hashFile(plan.compose.path) === plan.compose.sourceDigest, 'IDP_LOCAL_INPUT_CHANGED', '项目Manifest、配置合同或Compose已变化,请重新生成Plan');
249
+ invariant(hashFile(plan.config.environmentFile) === plan.config.environmentDigest, 'IDP_LOCAL_CONFIG_CHANGED', '应用.env已变化,请重新生成Plan');
250
+ for (const binding of plan.config.fileBindings) invariant(hashFile(path.join(plan.config.directory, binding.path)) === binding.digest, 'IDP_LOCAL_CONFIG_CHANGED', `${binding.name}已变化,请重新生成Plan`);
251
+ const normalized = normalizedCompose({ projectRoot: plan.project.root, composeFile: plan.compose.path, envFile: plan.config.environmentFile, composeProject: plan.composeProject, runner });
252
+ invariant(sha256(normalized.bytes) === plan.compose.normalizedDigest, 'IDP_LOCAL_COMPOSE_RENDER_CHANGED', 'Compose规范化结果已变化,请重新生成Plan');
253
+ return normalized;
254
+ }
255
+
256
+ function statePath(root, id) {
257
+ const directory = resolveContained(root, 'runtime/local-applications', '本地应用运行态目录');
258
+ secureDirectoryRoot(directory, '本地应用运行态目录', { create: true });
259
+ return path.join(directory, `${id}.json`);
260
+ }
261
+
262
+ function evidence(root, plan, action, status, details = {}) {
263
+ const directory = resolveContained(root, 'evidence/local-applications', '本地应用Evidence目录');
264
+ secureDirectoryRoot(directory, '本地应用Evidence目录', { create: true });
265
+ const core = { schemaVersion: 'idp.local-source-deployment-evidence/v1', application: plan.application, planId: plan.planId, action, status, recordedAt: new Date().toISOString(), details };
266
+ const receipt = { ...core, receiptId: sha256(stableJson(core)) };
267
+ const target = path.join(directory, `${receipt.receiptId.slice(7)}.json`);
268
+ if (!fs.existsSync(target)) atomicWrite(target, `${JSON.stringify(receipt, null, 2)}\n`, 0o600);
269
+ return receipt;
270
+ }
271
+
272
+ function parsePs(stdout) {
273
+ const text = stdout.trim();
274
+ if (!text) return [];
275
+ try { const value = JSON.parse(text); return Array.isArray(value) ? value : [value]; }
276
+ catch { return text.split(/\r?\n/u).filter(Boolean).map((line) => JSON.parse(line)); }
277
+ }
278
+
279
+ function activeState(root, plan, { required = true } = {}) {
280
+ const candidate = statePath(root, plan.application);
281
+ if (!fs.existsSync(candidate)) {
282
+ invariant(!required, 'IDP_LOCAL_RUNTIME_STATE_MISSING', '本地应用尚无受管运行态');
283
+ return { candidate, bytes: null, state: null };
284
+ }
285
+ assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: '本地应用运行态' });
286
+ const bytes = fs.readFileSync(candidate);
287
+ const state = JSON.parse(bytes.toString('utf8'));
288
+ if (required) invariant(state.planId === plan.planId, 'IDP_LOCAL_RUNTIME_PLAN_MISMATCH', '当前运行态属于另一份Plan,请使用当前Plan操作');
289
+ return { candidate, bytes, state };
290
+ }
291
+
292
+ export function verifyLocalSourceDeployment({ configRoot, applicationConfigRoot, planFile, runner = run, requireActiveState = true }) {
293
+ const { plan, root } = readPlan(configRoot, planFile, applicationConfigRoot);
294
+ revalidate(plan, runner);
295
+ activeState(root, plan, { required: requireActiveState });
296
+ const ps = parsePs(executeCompose(runner, composeArgs(plan, ['ps', '--format', 'json']), plan.project.root, true).stdout);
297
+ invariant(ps.length > 0, 'IDP_LOCAL_DEPLOYMENT_NOT_RUNNING', '本地应用没有运行中的Compose服务');
298
+ for (const service of ps) {
299
+ const state = String(service.State ?? service.state ?? '').toLowerCase();
300
+ const health = String(service.Health ?? service.health ?? '').toLowerCase();
301
+ invariant(state === 'running', 'IDP_LOCAL_DEPLOYMENT_UNHEALTHY', `服务${service.Service ?? service.Name ?? ''}未运行`);
302
+ invariant(!health || health === 'healthy', 'IDP_LOCAL_DEPLOYMENT_UNHEALTHY', `服务${service.Service ?? service.Name ?? ''}健康状态为${health}`);
303
+ }
304
+ return evidence(root, plan, 'verify', 'verified', { services: ps.map((item) => item.Service ?? item.Name).filter(Boolean) });
305
+ }
306
+
307
+ export function applyLocalSourceDeployment({ configRoot, applicationConfigRoot, planFile, runner = run }) {
308
+ const { plan, root, candidate: canonicalPlanFile } = readPlan(configRoot, planFile, applicationConfigRoot);
309
+ revalidate(plan, runner);
310
+ const current = activeState(root, plan, { required: false });
311
+ const stateFile = current.candidate;
312
+ const before = current.bytes;
313
+ const preimageFile = path.join(path.dirname(stateFile), `.${plan.application}.${plan.planId.slice(7, 19)}.preimage.json`);
314
+ if (before) atomicWrite(preimageFile, before, 0o600);
315
+ try {
316
+ executeCompose(runner, composeArgs(plan, ['build']), plan.project.root, false);
317
+ executeCompose(runner, composeArgs(plan, ['up', '-d', '--wait', '--remove-orphans']), plan.project.root, false);
318
+ const afterCommands = fs.existsSync(stateFile) ? fs.readFileSync(stateFile) : null;
319
+ invariant((before === null && afterCommands === null) || (before !== null && afterCommands !== null && before.equals(afterCommands)), 'IDP_LOCAL_RUNTIME_STATE_CHANGED', '执行期间运行态被并发修改,拒绝提交Receipt');
320
+ const receipt = verifyLocalSourceDeployment({ configRoot, applicationConfigRoot, planFile, runner, requireActiveState: false });
321
+ atomicWrite(stateFile, `${JSON.stringify({ schemaVersion: 'idp.local-source-runtime/v1', application: plan.application, planId: plan.planId, composeProject: plan.composeProject, composeFile: plan.compose.path, planFile: canonicalPlanFile, receiptId: receipt.receiptId }, null, 2)}\n`, 0o600);
322
+ return evidence(root, plan, 'apply', 'verified', { verifyReceiptId: receipt.receiptId });
323
+ } catch (error) {
324
+ try {
325
+ if (before && current.state?.planFile) {
326
+ const previous = readPlan(configRoot, current.state.planFile, applicationConfigRoot).plan;
327
+ revalidate(previous, runner);
328
+ executeCompose(runner, composeArgs(previous, ['up', '-d', '--wait', '--remove-orphans']), previous.project.root, false);
329
+ }
330
+ else executeCompose(runner, composeArgs(plan, ['down', '--remove-orphans']), plan.project.root, false);
331
+ } catch {}
332
+ evidence(root, plan, 'apply', 'failed', { code: error.code ?? 'IDP_PROCESS_FAILED' });
333
+ throw error;
334
+ }
335
+ }
336
+
337
+ export function stopLocalSourceDeployment({ configRoot, applicationConfigRoot, planFile, runner = run }) {
338
+ const { plan, root } = readPlan(configRoot, planFile, applicationConfigRoot);
339
+ revalidate(plan, runner);
340
+ activeState(root, plan);
341
+ executeCompose(runner, composeArgs(plan, ['stop']), plan.project.root, false);
342
+ return evidence(root, plan, 'stop', 'stopped');
343
+ }
344
+
345
+ export function removeLocalSourceDeployment({ configRoot, applicationConfigRoot, planFile, confirmation, runner = run }) {
346
+ const { plan, root } = readPlan(configRoot, planFile, applicationConfigRoot);
347
+ invariant(confirmation === plan.application, 'IDP_LOCAL_REMOVE_CONFIRMATION_REQUIRED', `remove必须通过--confirm ${plan.application}精确确认`);
348
+ revalidate(plan, runner);
349
+ activeState(root, plan);
350
+ executeCompose(runner, composeArgs(plan, ['down', '--remove-orphans']), plan.project.root, false);
351
+ const candidate = statePath(root, plan.application);
352
+ if (fs.existsSync(candidate)) fs.unlinkSync(candidate);
353
+ return evidence(root, plan, 'remove', 'removed', { volumesRemoved: false });
354
+ }
@@ -0,0 +1,10 @@
1
+ const PINNED_OFFICIAL_IMAGE = /^(?:(?:docker\.io|registry-1\.docker\.io)\/)?(?:library\/)?([a-z0-9][a-z0-9._-]*)(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?@((?:sha256:)[0-9a-f]{64})$/u;
2
+
3
+ export function dockerOfficialMirrorRef(ref) {
4
+ const match = PINNED_OFFICIAL_IMAGE.exec(ref ?? '');
5
+ return match ? `mirror.gcr.io/library/${match[1]}@${match[2]}` : null;
6
+ }
7
+
8
+ export function isDockerHubRateLimit(message) {
9
+ return /(?:429 Too Many Requests|toomanyrequests|rate limit)/iu.test(message ?? '');
10
+ }