@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/config.mjs ADDED
@@ -0,0 +1,903 @@
1
+ import fs from 'node:fs';
2
+ import crypto from 'node:crypto';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { IdpError, invariant } from './errors.mjs';
7
+ import {
8
+ assertDisjointRoots,
9
+ assertMode,
10
+ assertOutsideRepositories,
11
+ assertSafeDirectory,
12
+ assertSafeRegularFile,
13
+ atomicWrite,
14
+ canonicalPlannedDirectory,
15
+ canonicalExisting,
16
+ createExclusiveFile,
17
+ parseEnv,
18
+ resolveContained,
19
+ } from './security.mjs';
20
+ import { hashFile, sha256, stableJson } from './hash.mjs';
21
+ import { readImageLock, REQUIRED_IMAGE_PLATFORMS } from './image-lock.mjs';
22
+ import { resolveProfile } from './profiles.mjs';
23
+ import { validateBenchSnapshot, validateDyytoSnapshot } from './source-contracts.mjs';
24
+
25
+ export const COMPONENTS = ['tech', 'dyyto', 'bench', 'forge', 'flow', 'portal', 'smartgo'];
26
+
27
+ function rootEnv() {
28
+ const processUid = typeof process.getuid === 'function' ? process.getuid() : 1000;
29
+ const processGid = typeof process.getgid === 'function' ? process.getgid() : 1000;
30
+ const uid = processUid > 0 ? processUid : 1000;
31
+ const gid = processGid > 0 ? processGid : 1000;
32
+ const runtimePlatform = os.arch() === 'arm64' ? 'linux/arm64' : 'linux/amd64';
33
+ return `# IDP宿主机非敏感配置;Secret只能通过*_FILE引用。
34
+ IDP_CONFIG_SCHEMA_VERSION=1
35
+ IDP_INSTANCE_ID=company-idp-mac-01
36
+ IDP_ENVIRONMENT=private
37
+ IDP_DATA_DIR=/Users/Shared/company/idp/data
38
+ IDP_BACKUP_DIR=/Users/Shared/company/idp/backups
39
+ IDP_LOG_DIR=/Users/Shared/company/idp/logs
40
+ IDP_RUNTIME_DIR=/Users/Shared/company/idp/runtime
41
+ IDP_RESTORE_DIR=/Users/Shared/company/idp/restore
42
+ IDP_CONTAINER_UID=${uid}
43
+ IDP_CONTAINER_GID=${gid}
44
+ IDP_RUNTIME_PLATFORM=${runtimePlatform}
45
+ IDP_HTTP_ENABLED=true
46
+ IDP_HTTP_BIND=127.0.0.1
47
+ IDP_HTTP_PORT=8080
48
+ IDP_SMARTGO_GOTOLOGY_PORT=13000
49
+ IDP_SMARTGO_OPERATIONS_PORT=13101
50
+ IDP_SMARTGO_STUDIO_PORT=13002
51
+ IDP_TLS_ENABLED=false
52
+ IDP_HTTPS_BIND=127.0.0.1
53
+ IDP_HTTPS_PORT=8443
54
+ IDP_PUBLIC_HOST=127.0.0.1
55
+ IDP_NETWORK_POLICY_MODE=loopback-only
56
+ IDP_ALLOWED_CIDR=127.0.0.0/8
57
+ IDP_HOST_FIREWALL_EVIDENCE_FILE=trust/host-firewall-policy.txt
58
+ IDP_REGISTRY_BIND=127.0.0.1
59
+ IDP_REGISTRY_PORT=4873
60
+ IDP_REGISTRY_PRIVATE_SCOPES=@aipt,@bench
61
+ IDP_OCI_REPOSITORY_ROOT=UNCONFIGURED
62
+ IDP_OCI_LAYOUT_ORAS_FALLBACK_ENABLED=false
63
+ IDP_OCI_LAYOUT_BUILDER=UNCONFIGURED
64
+ IDP_ORAS_BINARY_PATH=tools/oras/oras
65
+ IDP_ORAS_VERSION=UNCONFIGURED
66
+ IDP_ORAS_SHA256=UNCONFIGURED
67
+ IDP_ALLOW_NON_GIT_SMARTGO_SOURCE=true
68
+ IDP_IDENTITY_MODE=anonymous-private
69
+ IDP_OIDC_ENABLED=false
70
+ IDP_OIDC_ISSUER=
71
+ IDP_OIDC_CLIENT_ID=
72
+ IDP_OIDC_GROUP_CLAIM=groups
73
+ IDP_OIDC_GROUP_MAPPING_FILE=bindings/identity/group-mapping.json
74
+ IDP_OIDC_CLIENT_SECRET_FILE=secrets/infrastructure/identity/oidc-client-secret
75
+ IDP_TLS_CERT_FILE=certs/tls-certificate.pem
76
+ IDP_TLS_KEY_FILE=secrets/infrastructure/edge/tls-private-key.pem
77
+ IDP_POSTGRES_SUPERUSER_PASSWORD_FILE=secrets/infrastructure/postgresql/superuser-password
78
+ IDP_POSTGRES_TECH_PASSWORD_FILE=secrets/infrastructure/postgresql/tech-password
79
+ IDP_POSTGRES_PORTAL_PASSWORD_FILE=secrets/infrastructure/postgresql/portal-password
80
+ IDP_POSTGRES_SMARTGO_PASSWORD_FILE=components/smartgo/secrets/postgresql-password
81
+ IDP_REGISTRY_HTPASSWD_FILE=secrets/infrastructure/registry/htpasswd
82
+ IDP_REGISTRY_ACCEPTANCE_NPMRC_FILE=secrets/infrastructure/registry/acceptance-npmrc
83
+ IDP_POSTGRES_IMAGE=UNCONFIGURED
84
+ IDP_CADDY_IMAGE=UNCONFIGURED
85
+ IDP_REGISTRY_IMAGE=UNCONFIGURED
86
+ IDP_TECH_IMAGE=UNCONFIGURED
87
+ IDP_PORTAL_IMAGE=UNCONFIGURED
88
+ IDP_FLOW_IMAGE=UNCONFIGURED
89
+ IDP_SMARTGO_IMAGE=UNCONFIGURED
90
+ IDP_SMARTGO_OBJECT_STORE_IMAGE=UNCONFIGURED
91
+ IDP_SMARTGO_OBJECT_STORE_CLIENT_IMAGE=UNCONFIGURED
92
+ `;
93
+ }
94
+
95
+ const COMPONENT_ENVS = {
96
+ tech: `# Tech知识控制面;Secret路径均相对本组件Config Dir。\nTECH_DATABASE_URL_FILE=secrets/database-url\nTECH_OPERATOR_TOKEN_FILE=secrets/operator-token\nTECH_SOURCE_CREDENTIALS_MANIFEST_FILE=config/source-credentials.json\nTECH_AUTH_MODE=anonymous-private\nTECH_BIND_HOST=0.0.0.0\nTECH_PORT=3698\nTECH_OPERATOR_LOOPBACK_ONLY=false\nTECH_EMBEDDING_ENABLED=false\nOPENAI_BASE_URL_FILE=secrets/openai-base-url\nOPENAI_API_KEY_FILE=secrets/openai-api-key\nOPENAI_EMBED_MODEL=text-embedding-3-small\nTECH_OBJECT_STORAGE_MODE=none\nTECH_POLICY_REVISION=policy-v1\n`,
97
+ dyyto: `DYYTO_NPMRC_FILE=secrets/npmrc\nDYYTO_PACKAGE_CATALOG_DIRS_FILE=catalog/package-catalog-dirs\n`,
98
+ bench: `# Bench 直接消费IDP_CONFIG_DIR/bindings并将确定性投影写入IDP_CONFIG_DIR/snapshots。\nBENCH_NPMRC_FILE=secrets/npmrc\n`,
99
+ forge: `FORGE_NPMRC_FILE=secrets/npmrc\n`,
100
+ flow: `FLOW_MODE=snapshot\nFLOW_BIND_HOST=0.0.0.0\nFLOW_PORT=3710\nFLOW_HTTP_TIMEOUT_MS=5000\nFLOW_WRITE_MODE=disabled\nFLOW_OPERATOR_TOKEN_FILE=secrets/operator-token\nFLOW_TECH_REQUIRED=true\nFLOW_TECH_URL=http://tech:3698\nFLOW_TECH_CONTEXT_FILE=snapshots/tech-context-bundle.json\nFLOW_DYYTO_CATALOG_FILE=snapshots/dyyto-package-release-catalog.json\nFLOW_BENCH_SNAPSHOT_FILE=snapshots/bench-environment-snapshot.json\nFLOW_PROPOSAL_URL=\n`,
101
+ portal: `PORTAL_PUBLIC_URL=http://127.0.0.1:8080\nPORTAL_DATABASE_URL_FILE=secrets/database-url\nPORTAL_TECH_URL=http://tech:3698\nPORTAL_FLOW_URL=http://flow:3710\nPORTAL_DYYTO_CATALOG_FILE=snapshots/dyyto-package-release-catalog.json\nPORTAL_BENCH_SNAPSHOT_FILE=snapshots/bench-environment-snapshot.json\nPORTAL_DEVELOPER_WORKSPACE_FILE=config/developer-workspace.json\nPORTAL_FOUNDATION_DIRECTORY_FILE=snapshots/foundation-directory.json\nPORTAL_FOUNDATION_PROBE_TIMEOUT_MS=1500\nPORTAL_UPSTREAM_TIMEOUT_MS=1500\nPORTAL_FIXTURE_FALLBACK=false\n`,
102
+ smartgo: `# SmartGo受管业务Profile;Secret只能使用相对此组件目录的*_FILE引用。
103
+ NODE_ENV=development
104
+ SMARTGO_DEPLOYMENT_MODE=idp-managed
105
+ SMARTGO_AUTH_MODE=local-open
106
+ SMARTGO_BIND_HOST=127.0.0.1
107
+ SMARTGO_STUDIO_STATE_MODE=ephemeral-fixture
108
+ SMARTGO_STUDIO_DATA_DIRECTORY=/run/smartgo/studio
109
+ MEDIA_ASSET_REPOSITORY=minio
110
+ GOTOLOGY_PUBLIC_URL=http://127.0.0.1:13000/
111
+ OPERATIONS_PUBLIC_URL=http://127.0.0.1:13101/
112
+ STUDIO_PUBLIC_URL=http://127.0.0.1:13002/
113
+ SMARTGO_ARTIFACT_STORE_PROVIDER=s3
114
+ SMARTGO_MINIO_BUCKET=smartgo-artifacts
115
+ SMARTGO_MINIO_INTERNAL_ENDPOINT=http://smartgo-object-store:9000
116
+ SMARTGO_MINIO_PUBLIC_ENDPOINT=http://127.0.0.1:8080
117
+ SIGNED_OBJECT_URL_TTL_SECONDS=900
118
+
119
+ DATABASE_URL_FILE=secrets/database-url
120
+ DIRECT_DATABASE_URL_FILE=secrets/direct-database-url
121
+ SMARTGO_OPERATOR_TOKEN_FILE=secrets/operator-token
122
+ PLATFORM_IDENTITY_SERVICE_TOKEN_FILE=secrets/platform-identity-service-token
123
+ PLATFORM_IDENTITY_ACTIVATION_TOKEN_FILE=secrets/platform-identity-activation-token
124
+ PLATFORM_IDENTITY_CLIENT_SECRET_FILE=secrets/platform-identity-client-secret
125
+ ARTIFACT_RENDERER_CALLBACK_TOKEN_FILE=secrets/artifact-renderer-callback-token
126
+ ALIYUN_OSS_ACCESS_KEY_ID_FILE=secrets/aliyun-oss-access-key-id
127
+ ALIYUN_OSS_ACCESS_KEY_SECRET_FILE=secrets/aliyun-oss-access-key-secret
128
+ ARTIFACT_DELIVERY_S3_ACCESS_KEY_ID_FILE=secrets/artifact-delivery-s3-access-key-id
129
+ ARTIFACT_DELIVERY_S3_ACCESS_KEY_SECRET_FILE=secrets/artifact-delivery-s3-access-key-secret
130
+ WORKER_ARTIFACT_S3_ACCESS_KEY_ID_FILE=secrets/worker-artifact-s3-access-key-id
131
+ WORKER_ARTIFACT_S3_ACCESS_KEY_SECRET_FILE=secrets/worker-artifact-s3-access-key-secret
132
+ SMARTGO_MINIO_ROOT_USER_FILE=secrets/minio-root-user
133
+ SMARTGO_MINIO_ROOT_PASSWORD_FILE=secrets/minio-root-password
134
+ SMARTGO_NPMRC_FILE=secrets/npmrc
135
+ `,
136
+ };
137
+
138
+ const REFERENCE_KEYS_BY_SCOPE = Object.freeze({
139
+ root: ['IDP_HOST_FIREWALL_EVIDENCE_FILE', 'IDP_TLS_CERT_FILE', 'IDP_TLS_KEY_FILE', 'IDP_POSTGRES_SUPERUSER_PASSWORD_FILE', 'IDP_POSTGRES_TECH_PASSWORD_FILE', 'IDP_POSTGRES_PORTAL_PASSWORD_FILE', 'IDP_POSTGRES_SMARTGO_PASSWORD_FILE', 'IDP_REGISTRY_HTPASSWD_FILE', 'IDP_REGISTRY_ACCEPTANCE_NPMRC_FILE', 'IDP_OIDC_CLIENT_SECRET_FILE', 'IDP_OIDC_GROUP_MAPPING_FILE'],
140
+ tech: ['TECH_DATABASE_URL_FILE', 'TECH_OPERATOR_TOKEN_FILE', 'TECH_SOURCE_CREDENTIALS_MANIFEST_FILE', 'OPENAI_BASE_URL_FILE', 'OPENAI_API_KEY_FILE'],
141
+ dyyto: ['DYYTO_NPMRC_FILE', 'DYYTO_PACKAGE_CATALOG_DIRS_FILE'],
142
+ bench: ['BENCH_NPMRC_FILE'],
143
+ forge: ['FORGE_NPMRC_FILE'],
144
+ flow: ['FLOW_OPERATOR_TOKEN_FILE', 'FLOW_TECH_CONTEXT_FILE', 'FLOW_DYYTO_CATALOG_FILE', 'FLOW_BENCH_SNAPSHOT_FILE'],
145
+ portal: ['PORTAL_DATABASE_URL_FILE', 'PORTAL_DYYTO_CATALOG_FILE', 'PORTAL_BENCH_SNAPSHOT_FILE', 'PORTAL_DEVELOPER_WORKSPACE_FILE', 'PORTAL_FOUNDATION_DIRECTORY_FILE'],
146
+ smartgo: [
147
+ 'DATABASE_URL_FILE', 'DIRECT_DATABASE_URL_FILE', 'SMARTGO_OPERATOR_TOKEN_FILE',
148
+ 'PLATFORM_IDENTITY_SERVICE_TOKEN_FILE', 'PLATFORM_IDENTITY_ACTIVATION_TOKEN_FILE',
149
+ 'PLATFORM_IDENTITY_CLIENT_SECRET_FILE', 'ARTIFACT_RENDERER_CALLBACK_TOKEN_FILE',
150
+ 'ALIYUN_OSS_ACCESS_KEY_ID_FILE', 'ALIYUN_OSS_ACCESS_KEY_SECRET_FILE',
151
+ 'ARTIFACT_DELIVERY_S3_ACCESS_KEY_ID_FILE', 'ARTIFACT_DELIVERY_S3_ACCESS_KEY_SECRET_FILE',
152
+ 'WORKER_ARTIFACT_S3_ACCESS_KEY_ID_FILE', 'WORKER_ARTIFACT_S3_ACCESS_KEY_SECRET_FILE',
153
+ 'SMARTGO_MINIO_ROOT_USER_FILE', 'SMARTGO_MINIO_ROOT_PASSWORD_FILE', 'SMARTGO_NPMRC_FILE',
154
+ ],
155
+ });
156
+
157
+ const REQUIRED_DIRECTORIES = [
158
+ 'components', 'infrastructure', 'bindings', 'secrets/infrastructure',
159
+ 'certs', 'trust', 'tools', 'tools/oras', 'releases', 'snapshots', 'snapshots/history', 'imports', 'generated', 'bindings/identity',
160
+ ];
161
+
162
+ function ensureDirectory(candidate, mode = 0o700) {
163
+ if (!fs.existsSync(candidate)) fs.mkdirSync(candidate, { recursive: true, mode });
164
+ const stat = fs.lstatSync(candidate);
165
+ invariant(stat.isDirectory() && !stat.isSymbolicLink(), 'IDP_DIRECTORY_INVALID', `必须是普通目录:${candidate}`);
166
+ fs.chmodSync(candidate, mode);
167
+ }
168
+
169
+ function ensureFile(candidate, content, mode = 0o600) {
170
+ if (!fs.existsSync(candidate)) createExclusiveFile(candidate, content, mode);
171
+ assertSafeRegularFile(candidate, mode, { allowEmpty: true, role: '配置文件' });
172
+ }
173
+
174
+ function ensureEnvFile(candidate, template, configRoot, scope, { knownDefaultMigrations = {} } = {}) {
175
+ if (!fs.existsSync(candidate)) {
176
+ createExclusiveFile(candidate, template, 0o600);
177
+ return [];
178
+ }
179
+ assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: `${scope} .env` });
180
+ const before = fs.readFileSync(candidate, 'utf8');
181
+ const current = parseEnv(before, candidate);
182
+ const defaults = parseEnv(template, `${scope}默认.env`);
183
+ const missing = Object.entries(defaults).filter(([key]) => !(key in current));
184
+ const replacements = Object.entries(knownDefaultMigrations).filter(([key, migration]) => (
185
+ key in current && migration.from.includes(current[key]) && current[key] !== migration.to
186
+ ));
187
+ if (missing.length === 0 && replacements.length === 0) return [];
188
+ const preimageDirectory = path.join(configRoot, 'snapshots', 'config-preimages');
189
+ ensureDirectory(preimageDirectory, 0o700);
190
+ const stamp = new Date().toISOString().replace(/[:.]/gu, '-');
191
+ const preimage = path.join(preimageDirectory, `${stamp}-${scope}-${crypto.randomUUID()}.env.preimage`);
192
+ createExclusiveFile(preimage, before, 0o600);
193
+ let after = before;
194
+ for (const [key, migration] of replacements) {
195
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
196
+ after = after.replace(new RegExp(`^${escaped}=.*$`, 'mu'), `${key}=${migration.to}`);
197
+ }
198
+ if (missing.length > 0) {
199
+ const additions = missing.map(([key, value]) => `${key}=${value}`).join('\n');
200
+ after = `${after.trimEnd()}\n# 由config init补齐的新配置键;已有值未覆盖。\n${additions}\n`;
201
+ }
202
+ atomicWrite(candidate, after, 0o600);
203
+ return [...replacements.map(([key]) => key), ...missing.map(([key]) => key)];
204
+ }
205
+
206
+ const SMARTGO_KNOWN_DEFAULT_MIGRATIONS = Object.freeze({
207
+ GOTOLOGY_PUBLIC_URL: { from: ['http://127.0.0.1:8080/smartgo/gotology', 'http://127.0.0.1:8080/smartgo/gotology/'], to: 'http://127.0.0.1:13000/' },
208
+ OPERATIONS_PUBLIC_URL: { from: ['http://127.0.0.1:3101', 'http://127.0.0.1:3101/', 'http://127.0.0.1:8080/smartgo/operations', 'http://127.0.0.1:8080/smartgo/operations/'], to: 'http://127.0.0.1:13101/' },
209
+ STUDIO_PUBLIC_URL: { from: ['http://127.0.0.1:3002', 'http://127.0.0.1:3002/', 'http://127.0.0.1:8080/smartgo/studio', 'http://127.0.0.1:8080/smartgo/studio/'], to: 'http://127.0.0.1:13002/' },
210
+ SMARTGO_MINIO_PUBLIC_ENDPOINT: { from: ['http://127.0.0.1:8080/smartgo/objects', 'http://127.0.0.1:8080/smartgo/objects/'], to: 'http://127.0.0.1:8080' },
211
+ });
212
+
213
+ export function referencedFiles(root) {
214
+ const refs = [];
215
+ const envFiles = [
216
+ { scope: 'root', envFile: path.join(root, '.env') },
217
+ ...COMPONENTS.map((scope) => ({ scope, envFile: path.join(root, 'components', scope, '.env') })),
218
+ ];
219
+ for (const { scope, envFile } of envFiles) {
220
+ if (!fs.existsSync(envFile)) continue;
221
+ const values = parseEnv(fs.readFileSync(envFile, 'utf8'), envFile);
222
+ for (const [key, value] of Object.entries(values)) {
223
+ if (!key.endsWith('_FILE') || !value) continue;
224
+ const base = scope === 'root' ? root : path.join(root, 'components', scope);
225
+ refs.push({ scope, key, reference: value, absolute: resolveContained(base, value, key) });
226
+ }
227
+ }
228
+ return refs;
229
+ }
230
+
231
+ function readSingleLineReference(candidate, role) {
232
+ if (!fs.existsSync(candidate) || fs.statSync(candidate).size === 0) return null;
233
+ const value = fs.readFileSync(candidate, 'utf8').replace(/[\r\n]+$/u, '');
234
+ invariant(value.length > 0 && !/[\r\n]/u.test(value), 'IDP_DATABASE_CREDENTIAL_INVALID', `${role}必须是单行非空值`);
235
+ return value;
236
+ }
237
+
238
+ function validateDatabaseCredentialConsistency(root, active) {
239
+ const refs = new Map(referencedFiles(root).map((ref) => [`${ref.scope}.${ref.key}`, ref.absolute]));
240
+ const contracts = [
241
+ {
242
+ enabled: active.has('tech'), role: 'tech', database: 'tech',
243
+ password: refs.get('root.IDP_POSTGRES_TECH_PASSWORD_FILE'),
244
+ url: refs.get('tech.TECH_DATABASE_URL_FILE'),
245
+ },
246
+ {
247
+ enabled: active.has('portal'), role: 'portal', database: 'backstage',
248
+ password: refs.get('root.IDP_POSTGRES_PORTAL_PASSWORD_FILE'),
249
+ url: refs.get('portal.PORTAL_DATABASE_URL_FILE'),
250
+ },
251
+ {
252
+ enabled: active.has('smartgo'), role: 'smartgo', database: 'smartgo',
253
+ password: refs.get('root.IDP_POSTGRES_SMARTGO_PASSWORD_FILE'),
254
+ url: refs.get('smartgo.DATABASE_URL_FILE'),
255
+ },
256
+ {
257
+ enabled: active.has('smartgo'), role: 'smartgo', database: 'smartgo',
258
+ password: refs.get('root.IDP_POSTGRES_SMARTGO_PASSWORD_FILE'),
259
+ url: refs.get('smartgo.DIRECT_DATABASE_URL_FILE'),
260
+ },
261
+ ];
262
+ for (const contract of contracts) {
263
+ if (!contract.enabled || !contract.password || !contract.url) continue;
264
+ const password = readSingleLineReference(contract.password, `${contract.role}数据库密码`);
265
+ const rawUrl = readSingleLineReference(contract.url, `${contract.role}数据库URL`);
266
+ if (password === null || rawUrl === null) continue;
267
+ let parsed;
268
+ try { parsed = new URL(rawUrl); }
269
+ catch { invariant(false, 'IDP_DATABASE_URL_INVALID', `${contract.role}数据库URL不是有效URL`); }
270
+ let decodedPassword;
271
+ try { decodedPassword = decodeURIComponent(parsed.password); }
272
+ catch { invariant(false, 'IDP_DATABASE_URL_INVALID', `${contract.role}数据库URL密码编码无效`); }
273
+ invariant(
274
+ parsed.protocol === 'postgresql:' && parsed.username === contract.role && decodedPassword === password &&
275
+ parsed.hostname === 'postgresql' && parsed.port === '5432' && parsed.pathname === `/${contract.database}` &&
276
+ parsed.searchParams.get('schema') === 'public',
277
+ 'IDP_DATABASE_CREDENTIAL_MISMATCH', `${contract.role}数据库密码与连接URL不一致;必须在切换前原子更新两者`,
278
+ );
279
+ }
280
+ }
281
+
282
+ function validateSmartGoCredentialConsistency(root, active) {
283
+ if (!active.has('smartgo')) return;
284
+ const refs = new Map(referencedFiles(root).filter((ref) => ref.scope === 'smartgo').map((ref) => [ref.key, ref.absolute]));
285
+ const read = (key) => readSingleLineReference(refs.get(key), key);
286
+ const minioUser = read('SMARTGO_MINIO_ROOT_USER_FILE');
287
+ const minioPassword = read('SMARTGO_MINIO_ROOT_PASSWORD_FILE');
288
+ const deliveryUser = read('ARTIFACT_DELIVERY_S3_ACCESS_KEY_ID_FILE');
289
+ const deliveryPassword = read('ARTIFACT_DELIVERY_S3_ACCESS_KEY_SECRET_FILE');
290
+ const workerUser = read('WORKER_ARTIFACT_S3_ACCESS_KEY_ID_FILE');
291
+ const workerPassword = read('WORKER_ARTIFACT_S3_ACCESS_KEY_SECRET_FILE');
292
+ if ([minioUser, deliveryUser, workerUser].every((value) => value !== null)) {
293
+ invariant(minioUser === deliveryUser && minioUser === workerUser, 'IDP_SMARTGO_OBJECT_ACCESS_KEY_MISMATCH', 'SmartGo应用与受管MinIO Access Key ID不一致');
294
+ }
295
+ if ([minioPassword, deliveryPassword, workerPassword].every((value) => value !== null)) {
296
+ invariant(minioPassword === deliveryPassword && minioPassword === workerPassword, 'IDP_SMARTGO_OBJECT_SECRET_MISMATCH', 'SmartGo应用与受管MinIO Access Key Secret不一致');
297
+ }
298
+ }
299
+
300
+ function booleanValue(env, key) {
301
+ invariant(['true', 'false'].includes(env[key]), 'IDP_BOOLEAN_INVALID', `${key}只能是true或false`);
302
+ return env[key] === 'true';
303
+ }
304
+
305
+ const ORAS_VERSION_PATTERN = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u;
306
+ const SHA256_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
307
+ const BUILDX_BUILDER_PATTERN = /^[a-z0-9][a-z0-9_.-]{0,63}$/u;
308
+
309
+ export function resolveConfiguredOrasTool(root, env) {
310
+ const enabled = booleanValue(env, 'IDP_OCI_LAYOUT_ORAS_FALLBACK_ENABLED');
311
+ const builder = env.IDP_OCI_LAYOUT_BUILDER;
312
+ invariant(
313
+ builder === 'UNCONFIGURED' || BUILDX_BUILDER_PATTERN.test(builder ?? ''),
314
+ 'IDP_RELEASE_OCI_BUILDER_NAME_INVALID',
315
+ 'IDP_OCI_LAYOUT_BUILDER必须是安全的Buildx Builder名称或UNCONFIGURED',
316
+ );
317
+ const reference = env.IDP_ORAS_BINARY_PATH;
318
+ invariant(typeof reference === 'string' && reference.length > 0, 'IDP_RELEASE_ORAS_PATH_INVALID', 'IDP_ORAS_BINARY_PATH必须是Config Dir内的相对路径');
319
+ let binary;
320
+ try { binary = resolveContained(root, reference, 'IDP_ORAS_BINARY_PATH'); }
321
+ catch { throw new IdpError('IDP_RELEASE_ORAS_PATH_INVALID', 'IDP_ORAS_BINARY_PATH必须是Config Dir内无链接的相对路径'); }
322
+ const version = env.IDP_ORAS_VERSION;
323
+ const digest = env.IDP_ORAS_SHA256;
324
+ invariant(version === 'UNCONFIGURED' || ORAS_VERSION_PATTERN.test(version ?? ''), 'IDP_RELEASE_ORAS_VERSION_INVALID', 'IDP_ORAS_VERSION必须是精确三段SemVer或UNCONFIGURED');
325
+ invariant(digest === 'UNCONFIGURED' || SHA256_DIGEST_PATTERN.test(digest ?? ''), 'IDP_RELEASE_ORAS_DIGEST_INVALID', 'IDP_ORAS_SHA256必须是sha256摘要或UNCONFIGURED');
326
+ if (!enabled) return null;
327
+ invariant(builder !== 'UNCONFIGURED', 'IDP_RELEASE_OCI_BUILDER_REQUIRED', '启用OCI Layout回退时必须固定IDP_OCI_LAYOUT_BUILDER');
328
+ invariant(version !== 'UNCONFIGURED', 'IDP_RELEASE_ORAS_VERSION_REQUIRED', '启用OCI Layout回退时必须固定IDP_ORAS_VERSION');
329
+ invariant(digest !== 'UNCONFIGURED', 'IDP_RELEASE_ORAS_DIGEST_REQUIRED', '启用OCI Layout回退时必须固定IDP_ORAS_SHA256');
330
+ invariant(fs.existsSync(binary), 'IDP_RELEASE_ORAS_BINARY_MISSING', '启用OCI Layout回退时Config Dir内缺少ORAS工具');
331
+ let stat;
332
+ try { stat = fs.lstatSync(binary); }
333
+ catch { throw new IdpError('IDP_RELEASE_ORAS_BINARY_INVALID', 'Config Dir中的ORAS工具无法安全读取'); }
334
+ invariant(stat.isFile() && !stat.isSymbolicLink(), 'IDP_RELEASE_ORAS_BINARY_INVALID', 'Config Dir中的ORAS工具必须是无链接普通文件');
335
+ invariant(stat.nlink === 1, 'IDP_RELEASE_ORAS_BINARY_INVALID', 'Config Dir中的ORAS工具必须只有一个硬链接');
336
+ if (typeof process.getuid === 'function') invariant(stat.uid === process.getuid(), 'IDP_RELEASE_ORAS_OWNER_INVALID', 'ORAS工具必须由当前部署用户拥有');
337
+ invariant((stat.mode & 0o500) === 0o500 && (stat.mode & 0o077) === 0, 'IDP_RELEASE_ORAS_MODE_INVALID', 'ORAS工具必须仅允许owner读取和执行');
338
+ invariant(stat.size > 0 && stat.size <= 128 * 1024 * 1024, 'IDP_RELEASE_ORAS_SIZE_INVALID', 'ORAS工具必须非空且不能超过128 MiB');
339
+ let observedDigest;
340
+ try { observedDigest = hashFile(binary); }
341
+ catch { throw new IdpError('IDP_RELEASE_ORAS_BINARY_INVALID', 'Config Dir中的ORAS工具无法安全读取'); }
342
+ invariant(observedDigest === digest, 'IDP_RELEASE_ORAS_DIGEST_MISMATCH', 'ORAS工具SHA-256与IDP_ORAS_SHA256不一致');
343
+ return Object.freeze({ binary, version, digest, builder });
344
+ }
345
+
346
+ function portValue(env, key) {
347
+ invariant(/^\d{1,5}$/u.test(env[key] ?? ''), 'IDP_PORT_INVALID', `${key}必须是1到65535之间的端口`);
348
+ const value = Number(env[key]);
349
+ invariant(value >= 1 && value <= 65535, 'IDP_PORT_INVALID', `${key}必须是1到65535之间的端口`);
350
+ return value;
351
+ }
352
+
353
+ function isLoopback(address) {
354
+ return address === '::1' || (net.isIPv4(address) && address.startsWith('127.'));
355
+ }
356
+
357
+ function validateNetworkConfiguration(env, active) {
358
+ const httpEnabled = booleanValue(env, 'IDP_HTTP_ENABLED');
359
+ const tlsEnabled = booleanValue(env, 'IDP_TLS_ENABLED');
360
+ invariant(httpEnabled || tlsEnabled, 'IDP_EDGE_DISABLED', 'HTTP与TLS不能同时关闭');
361
+ for (const key of ['IDP_HTTP_BIND', 'IDP_HTTPS_BIND', 'IDP_REGISTRY_BIND']) {
362
+ invariant(net.isIP(env[key] ?? '') !== 0, 'IDP_BIND_INVALID', `${key}必须是明确的IPv4或IPv6地址`);
363
+ }
364
+ for (const key of ['IDP_HTTP_PORT', 'IDP_HTTPS_PORT', 'IDP_REGISTRY_PORT', 'IDP_SMARTGO_GOTOLOGY_PORT', 'IDP_SMARTGO_OPERATIONS_PORT', 'IDP_SMARTGO_STUDIO_PORT']) portValue(env, key);
365
+ if (active.has('smartgo')) {
366
+ const smartgoPorts = ['IDP_HTTP_PORT', 'IDP_SMARTGO_GOTOLOGY_PORT', 'IDP_SMARTGO_OPERATIONS_PORT', 'IDP_SMARTGO_STUDIO_PORT'].map((key) => env[key]);
367
+ invariant(new Set(smartgoPorts).size === smartgoPorts.length, 'IDP_SMARTGO_PORT_COLLISION', 'SmartGo三站点与主Edge端口不能重复');
368
+ }
369
+ invariant(/^(?:[a-zA-Z0-9.-]+|\[[0-9a-fA-F:]+\])$/u.test(env.IDP_PUBLIC_HOST ?? ''), 'IDP_PUBLIC_HOST_INVALID', 'IDP_PUBLIC_HOST只能是主机名或IP地址,不能包含协议、路径或端口');
370
+ for (const cidr of (env.IDP_ALLOWED_CIDR ?? '').split(',').map((value) => value.trim()).filter(Boolean)) {
371
+ const [address, prefix, extra] = cidr.split('/');
372
+ const family = net.isIP(address);
373
+ const numericPrefix = Number(prefix);
374
+ invariant(!extra && family && /^\d+$/u.test(prefix ?? '') && numericPrefix >= 0 && numericPrefix <= (family === 4 ? 32 : 128), 'IDP_CIDR_INVALID', `IDP_ALLOWED_CIDR包含无效网段:${cidr}`);
375
+ }
376
+ invariant(Boolean(env.IDP_ALLOWED_CIDR), 'IDP_CIDR_EMPTY', 'IDP_ALLOWED_CIDR不能为空');
377
+ const mode = env.IDP_NETWORK_POLICY_MODE;
378
+ invariant(['loopback-only', 'host-firewall'].includes(mode), 'IDP_NETWORK_POLICY_INVALID', 'IDP_NETWORK_POLICY_MODE只能是loopback-only或host-firewall');
379
+ const exposedBinds = [];
380
+ if (active.has('edge') && httpEnabled) exposedBinds.push(['IDP_HTTP_BIND', env.IDP_HTTP_BIND]);
381
+ if (active.has('edge') && tlsEnabled) exposedBinds.push(['IDP_HTTPS_BIND', env.IDP_HTTPS_BIND]);
382
+ if (active.has('registry')) exposedBinds.push(['IDP_REGISTRY_BIND', env.IDP_REGISTRY_BIND]);
383
+ if (mode === 'loopback-only') {
384
+ for (const [key, address] of exposedBinds) invariant(isLoopback(address), 'IDP_NON_LOOPBACK_BIND_FORBIDDEN', `${key}在loopback-only模式下必须绑定回环地址`);
385
+ } else {
386
+ const cidrs = env.IDP_ALLOWED_CIDR.split(',').map((value) => value.trim());
387
+ invariant(!cidrs.includes('0.0.0.0/0') && !cidrs.includes('::/0'), 'IDP_FIREWALL_CIDR_TOO_BROAD', 'host-firewall模式不允许全网CIDR');
388
+ const nonLoopbackBinds = exposedBinds.filter(([, address]) => !isLoopback(address));
389
+ if (nonLoopbackBinds.length > 0) {
390
+ invariant(tlsEnabled, 'IDP_NON_LOOPBACK_TLS_REQUIRED', '扩大Web监听范围时必须同时启用TLS');
391
+ invariant(!nonLoopbackBinds.some(([key]) => key === 'IDP_REGISTRY_BIND'), 'IDP_REGISTRY_NON_LOOPBACK_FORBIDDEN', 'Registry当前只提供HTTP直连,必须保持回环监听;需要内网访问时应通过TLS反向代理');
392
+ if (nonLoopbackBinds.some(([key]) => key === 'IDP_HTTP_BIND')) {
393
+ invariant(!isLoopback(env.IDP_HTTPS_BIND), 'IDP_HTTPS_BIND_REQUIRED', 'HTTP扩大监听仅用于跳转时,HTTPS也必须监听企业内网地址');
394
+ }
395
+ const publicHost = (env.IDP_PUBLIC_HOST ?? '').replace(/^\[|\]$/gu, '');
396
+ invariant(!net.isIP(publicHost) || !isLoopback(publicHost), 'IDP_PUBLIC_HOST_LOOPBACK_FORBIDDEN', '扩大Web监听范围时IDP_PUBLIC_HOST不能仍是回环地址');
397
+ }
398
+ }
399
+ return { httpEnabled, tlsEnabled, policyMode: mode, exposedBinds: exposedBinds.map(([key, address]) => ({ key, address })) };
400
+ }
401
+
402
+ function validateIdentityConfiguration(env) {
403
+ invariant(env.IDP_IDENTITY_MODE === 'anonymous-private', 'IDP_IDENTITY_MODE_INVALID', '当前轻量部署只支持IDP_IDENTITY_MODE=anonymous-private');
404
+ const oidcEnabled = booleanValue(env, 'IDP_OIDC_ENABLED');
405
+ invariant(!oidcEnabled, 'IDP_OIDC_NOT_IMPLEMENTED', '当前版本只保留标准OIDC升级合同,尚未接入身份执行面;不得把OIDC标记为已启用');
406
+ if (env.IDP_OIDC_ISSUER) validateHttpUrl(env.IDP_OIDC_ISSUER, 'IDP_OIDC_ISSUER');
407
+ invariant(/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/u.test(env.IDP_OIDC_GROUP_CLAIM ?? ''), 'IDP_OIDC_GROUP_CLAIM_INVALID', 'IDP_OIDC_GROUP_CLAIM格式无效');
408
+ }
409
+
410
+ export function registryPrivateScopes(env) {
411
+ const scopes = String(env.IDP_REGISTRY_PRIVATE_SCOPES ?? '').split(',').map((value) => value.trim()).filter(Boolean);
412
+ invariant(scopes.length > 0, 'IDP_REGISTRY_SCOPES_EMPTY', 'IDP_REGISTRY_PRIVATE_SCOPES至少要声明一个私有Scope');
413
+ invariant(scopes.includes('@aipt') && scopes.includes('@bench'), 'IDP_REGISTRY_REQUIRED_SCOPE_MISSING', '私有Scope至少必须包含@aipt与@bench');
414
+ invariant(new Set(scopes).size === scopes.length, 'IDP_REGISTRY_SCOPE_DUPLICATE', 'IDP_REGISTRY_PRIVATE_SCOPES不能重复');
415
+ for (const scope of scopes) invariant(/^@[a-z0-9][a-z0-9._-]{0,63}$/u.test(scope), 'IDP_REGISTRY_SCOPE_INVALID', `Registry私有Scope格式无效:${scope}`);
416
+ return scopes;
417
+ }
418
+
419
+ function validateEnvSafety(values, source) {
420
+ for (const key of Object.keys(values)) {
421
+ const sensitive = /(PASSWORD|PASSPHRASE|SECRET|TOKEN|API_KEY|PRIVATE_KEY|ACCESS_KEY|DATABASE_URL|CONNECTION_STRING|NPMRC|AUTHORIZATION|(?:^|_)CREDENTIALS?(?:_|$)|(?:^|_)DSN(?:_|$))/u.test(key);
422
+ invariant(!sensitive || key.endsWith('_FILE'), 'IDP_INLINE_SECRET_FORBIDDEN', `${source}中的${key}疑似敏感值,必须改成*_FILE引用`);
423
+ }
424
+ }
425
+
426
+ function validateHttpUrl(value, key, { required = true } = {}) {
427
+ if (!value && !required) return;
428
+ invariant(Boolean(value), 'IDP_URL_REQUIRED', `${key}不能为空`);
429
+ let parsed;
430
+ try { parsed = new URL(value); } catch { invariant(false, 'IDP_URL_INVALID', `${key}必须是绝对HTTP(S) URL`); }
431
+ invariant(['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password && !parsed.hash, 'IDP_URL_INVALID', `${key}必须是不含凭据和片段的绝对HTTP(S) URL`);
432
+ }
433
+
434
+ function positiveInteger(values, key, maximum = 300_000) {
435
+ invariant(/^\d+$/u.test(values[key] ?? ''), 'IDP_NUMBER_INVALID', `${key}必须是正整数`);
436
+ const value = Number(values[key]);
437
+ invariant(value > 0 && value <= maximum, 'IDP_NUMBER_INVALID', `${key}必须在1到${maximum}之间`);
438
+ }
439
+
440
+ function validateComponentConfiguration(active, rootEnvValues, componentValues) {
441
+ if (active.has('tech')) {
442
+ const tech = componentValues.tech;
443
+ invariant(tech.TECH_AUTH_MODE === 'anonymous-private', 'IDP_TECH_AUTH_MODE_INVALID', '当前无OIDC轻量部署只支持TECH_AUTH_MODE=anonymous-private');
444
+ invariant(tech.TECH_BIND_HOST === '0.0.0.0', 'IDP_TECH_BIND_INVALID', '容器内TECH_BIND_HOST必须固定为0.0.0.0,由宿主Edge控制暴露边界');
445
+ invariant(tech.TECH_PORT === '3698', 'IDP_TECH_PORT_INVALID', 'TECH_PORT必须与Compose和Edge合同固定为3698');
446
+ booleanValue(tech, 'TECH_OPERATOR_LOOPBACK_ONLY');
447
+ invariant(tech.TECH_OPERATOR_LOOPBACK_ONLY === 'false', 'IDP_TECH_OPERATOR_EDGE_MODE_REQUIRED', 'Tech只通过Edge暴露,TECH_OPERATOR_LOOPBACK_ONLY必须为false并使用Bearer Operator Token鉴权');
448
+ booleanValue(tech, 'TECH_EMBEDDING_ENABLED');
449
+ invariant(/^[a-z0-9][a-z0-9._-]{0,127}$/u.test(tech.TECH_POLICY_REVISION ?? ''), 'IDP_TECH_POLICY_REVISION_INVALID', 'TECH_POLICY_REVISION格式无效');
450
+ if (tech.TECH_EMBEDDING_ENABLED === 'true') {
451
+ invariant(Boolean(tech.OPENAI_EMBED_MODEL), 'IDP_TECH_EMBED_MODEL_REQUIRED', '启用Embedding时必须配置OPENAI_EMBED_MODEL');
452
+ }
453
+ invariant(tech.TECH_SOURCE_CREDENTIALS_MANIFEST_FILE === 'config/source-credentials.json', 'IDP_TECH_SOURCE_CREDENTIALS_PATH_INVALID', 'TECH_SOURCE_CREDENTIALS_MANIFEST_FILE必须固定为config/source-credentials.json');
454
+ invariant(tech.TECH_OBJECT_STORAGE_MODE === 'none', 'IDP_TECH_OBJECT_STORAGE_UNSUPPORTED', '当前Tech P0尚未实现对象正文/附件存储,TECH_OBJECT_STORAGE_MODE必须为none');
455
+ }
456
+ if (active.has('flow')) {
457
+ const flow = componentValues.flow;
458
+ invariant(['snapshot', 'http'].includes(flow.FLOW_MODE), 'IDP_FLOW_MODE_INVALID', 'FLOW_MODE只能是snapshot或http');
459
+ invariant(['disabled', 'operator'].includes(flow.FLOW_WRITE_MODE), 'IDP_FLOW_WRITE_MODE_INVALID', 'FLOW_WRITE_MODE只能是disabled或operator');
460
+ invariant(flow.FLOW_BIND_HOST === '0.0.0.0', 'IDP_FLOW_BIND_INVALID', '容器内FLOW_BIND_HOST必须固定为0.0.0.0,由宿主Edge控制暴露边界');
461
+ portValue(flow, 'FLOW_PORT');
462
+ invariant(flow.FLOW_PORT === '3710', 'IDP_FLOW_PORT_INVALID', 'FLOW_PORT必须与Compose和Edge合同固定为3710');
463
+ positiveInteger(flow, 'FLOW_HTTP_TIMEOUT_MS');
464
+ booleanValue(flow, 'FLOW_TECH_REQUIRED');
465
+ if (flow.FLOW_TECH_REQUIRED === 'true') validateHttpUrl(flow.FLOW_TECH_URL, 'FLOW_TECH_URL');
466
+ else validateHttpUrl(flow.FLOW_TECH_URL, 'FLOW_TECH_URL', { required: false });
467
+ if (flow.FLOW_MODE === 'http') {
468
+ validateHttpUrl(flow.FLOW_DYYTO_URL, 'FLOW_DYYTO_URL');
469
+ validateHttpUrl(flow.FLOW_BENCH_URL, 'FLOW_BENCH_URL');
470
+ }
471
+ if (flow.FLOW_WRITE_MODE === 'operator') validateHttpUrl(flow.FLOW_PROPOSAL_URL, 'FLOW_PROPOSAL_URL');
472
+ else invariant(!flow.FLOW_PROPOSAL_URL, 'IDP_FLOW_WRITE_DISABLED', 'FLOW_WRITE_MODE=disabled时FLOW_PROPOSAL_URL必须为空');
473
+ }
474
+ if (active.has('portal')) {
475
+ const portal = componentValues.portal;
476
+ validateHttpUrl(portal.PORTAL_PUBLIC_URL, 'PORTAL_PUBLIC_URL');
477
+ const externalScheme = rootEnvValues.IDP_TLS_ENABLED === 'true' ? 'https' : 'http';
478
+ const externalPort = rootEnvValues.IDP_TLS_ENABLED === 'true' ? rootEnvValues.IDP_HTTPS_PORT : rootEnvValues.IDP_HTTP_PORT;
479
+ const expectedOrigin = new URL(`${externalScheme}://${rootEnvValues.IDP_PUBLIC_HOST}:${externalPort}`).origin;
480
+ invariant(new URL(portal.PORTAL_PUBLIC_URL).origin === expectedOrigin, 'IDP_PORTAL_PUBLIC_URL_MISMATCH', `PORTAL_PUBLIC_URL必须与Edge外部Origin一致:${expectedOrigin}`);
481
+ validateHttpUrl(portal.PORTAL_TECH_URL, 'PORTAL_TECH_URL', { required: false });
482
+ validateHttpUrl(portal.PORTAL_FLOW_URL, 'PORTAL_FLOW_URL', { required: false });
483
+ positiveInteger(portal, 'PORTAL_FOUNDATION_PROBE_TIMEOUT_MS');
484
+ positiveInteger(portal, 'PORTAL_UPSTREAM_TIMEOUT_MS');
485
+ booleanValue(portal, 'PORTAL_FIXTURE_FALLBACK');
486
+ invariant(portal.PORTAL_FIXTURE_FALLBACK !== 'true' || rootEnvValues.IDP_ENVIRONMENT === 'development', 'IDP_PORTAL_FIXTURE_FORBIDDEN', 'PORTAL_FIXTURE_FALLBACK只允许development环境显式启用');
487
+ }
488
+ if (active.has('smartgo')) {
489
+ const smartgo = componentValues.smartgo;
490
+ invariant(smartgo.NODE_ENV === 'development', 'IDP_SMARTGO_NODE_ENV_INVALID', 'SmartGo local-open受管Profile当前只允许NODE_ENV=development');
491
+ invariant(smartgo.SMARTGO_DEPLOYMENT_MODE === 'idp-managed', 'IDP_SMARTGO_DEPLOYMENT_MODE_INVALID', 'SmartGo受管Profile必须使用SMARTGO_DEPLOYMENT_MODE=idp-managed');
492
+ invariant(smartgo.SMARTGO_AUTH_MODE === 'local-open', 'IDP_SMARTGO_AUTH_MODE_INVALID', 'SmartGo当前只支持SMARTGO_AUTH_MODE=local-open');
493
+ invariant(smartgo.SMARTGO_STUDIO_STATE_MODE === 'ephemeral-fixture', 'IDP_SMARTGO_STUDIO_STATE_MODE_INVALID', 'SmartGo local-open必须且只能使用SMARTGO_STUDIO_STATE_MODE=ephemeral-fixture');
494
+ invariant(smartgo.SMARTGO_STUDIO_DATA_DIRECTORY === '/run/smartgo/studio', 'IDP_SMARTGO_STUDIO_DATA_DIRECTORY_INVALID', 'SmartGo local-open Studio数据目录必须固定为绝对路径/run/smartgo/studio');
495
+ invariant(isLoopback(smartgo.SMARTGO_BIND_HOST), 'IDP_SMARTGO_BIND_INVALID', 'SmartGo local-open宿主发布边界必须是明确回环地址');
496
+ invariant(smartgo.SMARTGO_BIND_HOST === rootEnvValues.IDP_HTTP_BIND && smartgo.SMARTGO_BIND_HOST === rootEnvValues.IDP_PUBLIC_HOST, 'IDP_SMARTGO_BIND_MISMATCH', 'SmartGo回环Bind、Edge HTTP Bind与Public Host必须一致');
497
+ invariant(rootEnvValues.IDP_HTTP_ENABLED === 'true' && rootEnvValues.IDP_TLS_ENABLED === 'false', 'IDP_SMARTGO_LOCAL_OPEN_HTTP_REQUIRED', 'SmartGo local-open受管Profile当前只支持回环HTTP,不得伪装成OIDC/TLS生产模式');
498
+ invariant(smartgo.MEDIA_ASSET_REPOSITORY === 'minio' && smartgo.SMARTGO_ARTIFACT_STORE_PROVIDER === 's3', 'IDP_SMARTGO_OBJECT_STORE_INVALID', 'business-smartgo必须使用idp-deploy受管MinIO/S3对象存储');
499
+ invariant(smartgo.SMARTGO_MINIO_BUCKET === 'smartgo-artifacts', 'IDP_SMARTGO_BUCKET_INVALID', 'P0受管MinIO Bucket必须固定为smartgo-artifacts');
500
+ positiveInteger(smartgo, 'SIGNED_OBJECT_URL_TTL_SECONDS', 86_400);
501
+ validateHttpUrl(smartgo.GOTOLOGY_PUBLIC_URL, 'GOTOLOGY_PUBLIC_URL');
502
+ validateHttpUrl(smartgo.OPERATIONS_PUBLIC_URL, 'OPERATIONS_PUBLIC_URL');
503
+ validateHttpUrl(smartgo.STUDIO_PUBLIC_URL, 'STUDIO_PUBLIC_URL');
504
+ validateHttpUrl(smartgo.SMARTGO_MINIO_INTERNAL_ENDPOINT, 'SMARTGO_MINIO_INTERNAL_ENDPOINT');
505
+ validateHttpUrl(smartgo.SMARTGO_MINIO_PUBLIC_ENDPOINT, 'SMARTGO_MINIO_PUBLIC_ENDPOINT');
506
+ invariant(new URL(smartgo.SMARTGO_MINIO_INTERNAL_ENDPOINT).origin === 'http://smartgo-object-store:9000', 'IDP_SMARTGO_MINIO_ENDPOINT_INVALID', '受管MinIO内部Endpoint必须指向http://smartgo-object-store:9000');
507
+ invariant(new URL(smartgo.SMARTGO_MINIO_PUBLIC_ENDPOINT).origin === `http://${smartgo.SMARTGO_BIND_HOST}:${rootEnvValues.IDP_HTTP_PORT}` && new URL(smartgo.SMARTGO_MINIO_PUBLIC_ENDPOINT).pathname === '/', 'IDP_SMARTGO_MINIO_PUBLIC_ENDPOINT_INVALID', '受管MinIO Public Endpoint必须是主Edge无路径Origin');
508
+ const expectedOrigins = {
509
+ GOTOLOGY_PUBLIC_URL: `http://${smartgo.SMARTGO_BIND_HOST}:${rootEnvValues.IDP_SMARTGO_GOTOLOGY_PORT}`,
510
+ OPERATIONS_PUBLIC_URL: `http://${smartgo.SMARTGO_BIND_HOST}:${rootEnvValues.IDP_SMARTGO_OPERATIONS_PORT}`,
511
+ STUDIO_PUBLIC_URL: `http://${smartgo.SMARTGO_BIND_HOST}:${rootEnvValues.IDP_SMARTGO_STUDIO_PORT}`,
512
+ };
513
+ for (const [key, expected] of Object.entries(expectedOrigins)) {
514
+ invariant(new URL(smartgo[key]).origin === expected && new URL(smartgo[key]).pathname === '/', 'IDP_SMARTGO_PUBLIC_URL_MISMATCH', `${key}必须是无路径前缀的独立回环Origin:${expected}/`);
515
+ }
516
+ }
517
+ }
518
+
519
+ function isReferenceRequired(ref, active, rootEnvValues, componentValues) {
520
+ if (['IDP_TLS_CERT_FILE', 'IDP_TLS_KEY_FILE'].includes(ref.key)) return rootEnvValues.IDP_TLS_ENABLED === 'true' && active.has('edge');
521
+ if (ref.key === 'IDP_HOST_FIREWALL_EVIDENCE_FILE') return rootEnvValues.IDP_NETWORK_POLICY_MODE === 'host-firewall' && (active.has('edge') || active.has('registry'));
522
+ if (ref.key === 'IDP_POSTGRES_SUPERUSER_PASSWORD_FILE') return active.has('postgresql');
523
+ if (['IDP_POSTGRES_TECH_PASSWORD_FILE', 'IDP_POSTGRES_PORTAL_PASSWORD_FILE'].includes(ref.key)) return active.has('postgresql');
524
+ if (ref.key === 'IDP_POSTGRES_SMARTGO_PASSWORD_FILE') return active.has('smartgo');
525
+ if (ref.key === 'IDP_REGISTRY_HTPASSWD_FILE') return false;
526
+ if (ref.key === 'IDP_REGISTRY_ACCEPTANCE_NPMRC_FILE') return false;
527
+ if (['IDP_OIDC_CLIENT_SECRET_FILE', 'IDP_OIDC_GROUP_MAPPING_FILE'].includes(ref.key)) return rootEnvValues.IDP_OIDC_ENABLED === 'true';
528
+ if (['DYYTO_NPMRC_FILE', 'DYYTO_PACKAGE_CATALOG_DIRS_FILE', 'BENCH_NPMRC_FILE', 'FORGE_NPMRC_FILE'].includes(ref.key)) return false;
529
+ if (ref.key === 'FLOW_OPERATOR_TOKEN_FILE') return active.has('flow') && componentValues.flow?.FLOW_WRITE_MODE === 'operator';
530
+ if (ref.key === 'FLOW_TECH_CONTEXT_FILE') return active.has('flow') && componentValues.flow?.FLOW_TECH_REQUIRED === 'false';
531
+ if (['FLOW_DYYTO_CATALOG_FILE', 'FLOW_BENCH_SNAPSHOT_FILE'].includes(ref.key)) return active.has('flow') && componentValues.flow?.FLOW_MODE === 'snapshot';
532
+ if (['PORTAL_DYYTO_CATALOG_FILE', 'PORTAL_BENCH_SNAPSHOT_FILE'].includes(ref.key)) return active.has('portal') && componentValues.portal?.PORTAL_FIXTURE_FALLBACK !== 'true';
533
+ if (ref.key === 'PORTAL_FOUNDATION_DIRECTORY_FILE') return false;
534
+ if (ref.scope === 'smartgo') {
535
+ if (!active.has('smartgo')) return false;
536
+ if (['ALIYUN_OSS_ACCESS_KEY_ID_FILE', 'ALIYUN_OSS_ACCESS_KEY_SECRET_FILE', 'SMARTGO_NPMRC_FILE'].includes(ref.key)) return false;
537
+ return true;
538
+ }
539
+ if (['OPENAI_BASE_URL_FILE', 'OPENAI_API_KEY_FILE'].includes(ref.key)) return active.has('tech') && componentValues.tech?.TECH_EMBEDDING_ENABLED === 'true';
540
+ if (ref.scope === 'root') return true;
541
+ return active.has(ref.scope);
542
+ }
543
+
544
+ function assertRequiredReferenceDeclarations(active, rootEnvValues, componentValues) {
545
+ for (const [scope, keys] of Object.entries(REFERENCE_KEYS_BY_SCOPE)) {
546
+ const values = scope === 'root' ? rootEnvValues : componentValues[scope];
547
+ for (const key of keys) {
548
+ const ref = { scope, key };
549
+ if (isReferenceRequired(ref, active, rootEnvValues, componentValues)) {
550
+ invariant(typeof values[key] === 'string' && values[key].length > 0, 'IDP_REFERENCE_PATH_REQUIRED', `${scope}.${key}必须声明非空的Config Dir相对文件路径`);
551
+ }
552
+ }
553
+ }
554
+ }
555
+
556
+ function validateSourceReference(ref, stat) {
557
+ if (ref.key === 'OPENAI_BASE_URL_FILE') {
558
+ invariant(stat.size <= 4096, 'IDP_TECH_OPENAI_URL_INVALID', 'OPENAI_BASE_URL_FILE不能超过4 KiB');
559
+ const value = fs.readFileSync(ref.absolute, 'utf8').replace(/[\r\n]+$/u, '');
560
+ invariant(value.length > 0 && !/[\r\n]/u.test(value), 'IDP_TECH_OPENAI_URL_INVALID', 'OPENAI_BASE_URL_FILE必须是单行URL');
561
+ validateHttpUrl(value, 'OPENAI_BASE_URL_FILE');
562
+ const parsed = new URL(value);
563
+ const local = parsed.hostname === 'localhost' || isLoopback(parsed.hostname);
564
+ invariant(parsed.protocol === 'https:' || local, 'IDP_TECH_OPENAI_TLS_REQUIRED', '非本机Embedding Provider必须使用HTTPS');
565
+ return;
566
+ }
567
+ const validators = {
568
+ FLOW_DYYTO_CATALOG_FILE: validateDyytoSnapshot,
569
+ PORTAL_DYYTO_CATALOG_FILE: validateDyytoSnapshot,
570
+ FLOW_BENCH_SNAPSHOT_FILE: validateBenchSnapshot,
571
+ PORTAL_BENCH_SNAPSHOT_FILE: validateBenchSnapshot,
572
+ };
573
+ const validator = validators[ref.key];
574
+ if (!validator) return;
575
+ invariant(stat.size <= 16 * 1024 * 1024, 'IDP_SOURCE_SIZE_INVALID', `${ref.key}不能超过16 MiB`);
576
+ let document;
577
+ try { document = JSON.parse(fs.readFileSync(ref.absolute, 'utf8')); }
578
+ catch { invariant(false, 'IDP_SOURCE_JSON_INVALID', `${ref.key}不是有效JSON`); }
579
+ validator(document);
580
+ }
581
+
582
+ function parseTechSourceCredentialsManifest(candidate) {
583
+ const stat = assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: 'Tech来源凭据清单' });
584
+ invariant(stat.size <= 64 * 1024, 'IDP_TECH_SOURCE_CREDENTIALS_MANIFEST_TOO_LARGE', 'Tech来源凭据清单不能超过64 KiB');
585
+ let document;
586
+ try { document = JSON.parse(fs.readFileSync(candidate, 'utf8')); }
587
+ catch { invariant(false, 'IDP_TECH_SOURCE_CREDENTIALS_MANIFEST_JSON_INVALID', 'Tech来源凭据清单不是有效JSON'); }
588
+ invariant(document && typeof document === 'object' && !Array.isArray(document), 'IDP_TECH_SOURCE_CREDENTIALS_MANIFEST_SCHEMA_INVALID', 'Tech来源凭据清单必须是JSON对象');
589
+ const keys = Object.keys(document).sort();
590
+ invariant(keys.length === 2 && keys[0] === 'files' && keys[1] === 'schemaVersion', 'IDP_TECH_SOURCE_CREDENTIALS_MANIFEST_SCHEMA_INVALID', 'Tech来源凭据清单只能包含schemaVersion与files');
591
+ invariant(document.schemaVersion === 1 && Array.isArray(document.files), 'IDP_TECH_SOURCE_CREDENTIALS_MANIFEST_SCHEMA_INVALID', 'Tech来源凭据清单必须符合v1合同');
592
+ invariant(document.files.length <= 200, 'IDP_TECH_SOURCE_CREDENTIALS_LIMIT_EXCEEDED', 'Tech来源凭据清单最多允许200个文件');
593
+ const reserved = new Set(['database-url', 'operator-token', 'openai-api-key', 'openai-base-url', 'source-credentials.json']);
594
+ let previous;
595
+ for (const filename of document.files) {
596
+ invariant(typeof filename === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/u.test(filename), 'IDP_TECH_SOURCE_CREDENTIAL_FILENAME_INVALID', 'Tech来源凭据文件名必须是1到200字符的安全basename');
597
+ invariant(!reserved.has(filename), 'IDP_TECH_SOURCE_CREDENTIAL_FILENAME_RESERVED', `Tech来源凭据不得使用固定Secret保留名:${filename}`);
598
+ invariant(previous === undefined || previous < filename, 'IDP_TECH_SOURCE_CREDENTIALS_ORDER_INVALID', 'Tech来源凭据文件名必须唯一并按字典序严格升序排列');
599
+ previous = filename;
600
+ }
601
+ return document.files;
602
+ }
603
+
604
+ function validateTechSourceCredentials(root, tech, { requireConfigured }) {
605
+ const manifestReference = tech.TECH_SOURCE_CREDENTIALS_MANIFEST_FILE;
606
+ const componentRoot = resolveContained(root, 'components/tech', 'Tech组件配置目录');
607
+ const credentialsRoot = resolveContained(componentRoot, 'secrets/source-credentials', 'Tech来源凭据专属目录');
608
+ assertSafeDirectory(credentialsRoot, 0o700, { role: 'Tech来源凭据专属目录' });
609
+ const manifestPath = resolveContained(componentRoot, manifestReference, 'Tech来源凭据清单引用');
610
+ if (!fs.existsSync(manifestPath) || fs.statSync(manifestPath).size === 0) return { manifestReference, manifestPath, files: [] };
611
+ const filenames = parseTechSourceCredentialsManifest(manifestPath);
612
+ const files = filenames.map((filename) => {
613
+ const reference = `secrets/source-credentials/${filename}`;
614
+ const absolute = resolveContained(componentRoot, reference, `Tech来源凭据${filename}`);
615
+ if (fs.existsSync(absolute)) {
616
+ assertSafeRegularFile(absolute, 0o600, { allowEmpty: !requireConfigured, role: `Tech来源凭据${filename}` });
617
+ } else if (requireConfigured) {
618
+ invariant(false, 'IDP_TECH_SOURCE_CREDENTIAL_MISSING', `Tech来源凭据文件不存在:${filename}`);
619
+ }
620
+ return { filename, reference, absolute };
621
+ });
622
+ return { manifestReference, manifestPath, files };
623
+ }
624
+
625
+ function requiredImages(active) {
626
+ const result = [];
627
+ if (active.has('postgresql')) result.push('IDP_POSTGRES_IMAGE');
628
+ if (active.has('registry')) result.push('IDP_REGISTRY_IMAGE');
629
+ if (active.has('tech')) result.push('IDP_TECH_IMAGE');
630
+ if (active.has('portal')) result.push('IDP_PORTAL_IMAGE');
631
+ if (active.has('flow')) result.push('IDP_FLOW_IMAGE');
632
+ if (active.has('smartgo')) result.push('IDP_SMARTGO_IMAGE', 'IDP_SMARTGO_OBJECT_STORE_IMAGE', 'IDP_SMARTGO_OBJECT_STORE_CLIENT_IMAGE');
633
+ if (active.has('edge')) result.push('IDP_CADDY_IMAGE');
634
+ return result;
635
+ }
636
+
637
+ function configurationValues(root) {
638
+ const componentsRoot = resolveContained(root, 'components', '组件配置根');
639
+ assertSafeDirectory(componentsRoot, 0o700, { role: '组件配置根' });
640
+ const rootEnvPath = path.join(root, '.env');
641
+ assertSafeRegularFile(rootEnvPath, 0o600, { allowEmpty: false, role: '根.env' });
642
+ const env = parseEnv(fs.readFileSync(rootEnvPath, 'utf8'), rootEnvPath);
643
+ validateEnvSafety(env, rootEnvPath);
644
+ const components = Object.create(null);
645
+ for (const component of COMPONENTS) {
646
+ const componentRoot = resolveContained(root, `components/${component}`, `${component}配置目录`);
647
+ assertSafeDirectory(componentRoot, 0o700, { role: `${component}配置目录` });
648
+ const candidate = resolveContained(root, `components/${component}/.env`, `${component} .env`);
649
+ assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: `${component} .env` });
650
+ components[component] = parseEnv(fs.readFileSync(candidate, 'utf8'), candidate);
651
+ validateEnvSafety(components[component], candidate);
652
+ const scopeConflicts = Object.keys(components[component]).filter((key) => key in env);
653
+ invariant(scopeConflicts.length === 0, 'IDP_ENV_SCOPE_CONFLICT', `${component} .env不能重复定义根配置键:${scopeConflicts.join(',')}`);
654
+ }
655
+ return { env, components };
656
+ }
657
+
658
+ function materializeReferences(root) {
659
+ const created = [];
660
+ for (const ref of referencedFiles(root)) {
661
+ if (ref.key === 'PORTAL_FOUNDATION_DIRECTORY_FILE' && !fs.existsSync(ref.absolute)) continue;
662
+ ensureDirectory(path.dirname(ref.absolute));
663
+ if (!fs.existsSync(ref.absolute)) {
664
+ const initialContent = ref.key === 'TECH_SOURCE_CREDENTIALS_MANIFEST_FILE'
665
+ ? `${JSON.stringify({ schemaVersion: 1, files: [] }, null, 2)}\n`
666
+ : ref.key === 'PORTAL_DEVELOPER_WORKSPACE_FILE'
667
+ ? `${JSON.stringify({
668
+ schemaVersion: 'portal.developer-workspace/v1',
669
+ generatedAt: new Date().toISOString(),
670
+ workspaces: [],
671
+ environments: [],
672
+ }, null, 2)}\n`
673
+ : '';
674
+ ensureFile(ref.absolute, initialContent, ref.key.includes('CERT') && !ref.key.includes('KEY') ? 0o644 : 0o600);
675
+ created.push(ref.reference);
676
+ } else {
677
+ assertSafeRegularFile(ref.absolute, ref.key.includes('CERT') && !ref.key.includes('KEY') ? 0o644 : 0o600, { allowEmpty: true, role: ref.key });
678
+ }
679
+ }
680
+ return created;
681
+ }
682
+
683
+ export function initializeConfig(configRoot) {
684
+ invariant(path.isAbsolute(configRoot), 'IDP_CONFIG_NOT_ABSOLUTE', 'IDP_CONFIG_DIR必须是绝对路径');
685
+ assertOutsideRepositories(configRoot, 'IDP_CONFIG_DIR');
686
+ ensureDirectory(configRoot, 0o700);
687
+ const canonicalRoot = canonicalExisting(configRoot, 'IDP_CONFIG_DIR');
688
+ assertOutsideRepositories(canonicalRoot, 'IDP_CONFIG_DIR');
689
+ for (const relative of REQUIRED_DIRECTORIES) ensureDirectory(path.join(canonicalRoot, relative));
690
+ for (const component of COMPONENTS) {
691
+ ensureDirectory(path.join(canonicalRoot, 'components', component));
692
+ ensureDirectory(path.join(canonicalRoot, 'components', component, 'config'));
693
+ if (component === 'tech') ensureDirectory(path.join(canonicalRoot, 'components', component, 'secrets', 'source-credentials'));
694
+ ensureEnvFile(
695
+ path.join(canonicalRoot, 'components', component, '.env'),
696
+ COMPONENT_ENVS[component],
697
+ canonicalRoot,
698
+ `component-${component}`,
699
+ component === 'smartgo' ? { knownDefaultMigrations: SMARTGO_KNOWN_DEFAULT_MIGRATIONS } : undefined,
700
+ );
701
+ }
702
+ for (const infrastructure of ['edge', 'postgresql', 'registry', 'identity']) {
703
+ ensureDirectory(path.join(canonicalRoot, 'infrastructure', infrastructure));
704
+ ensureDirectory(path.join(canonicalRoot, 'secrets', 'infrastructure', infrastructure));
705
+ }
706
+ ensureEnvFile(path.join(canonicalRoot, '.env'), rootEnv(), canonicalRoot, 'root');
707
+ ensureFile(path.join(canonicalRoot, 'host.json'), `${JSON.stringify({ schemaVersion: 1, hostname: os.hostname(), platform: os.platform(), arch: os.arch(), createdAt: new Date().toISOString() }, null, 2)}\n`);
708
+ materializeReferences(canonicalRoot);
709
+ return doctorConfig(canonicalRoot, { requireConfigured: false });
710
+ }
711
+
712
+ export function generateSecrets(configRoot) {
713
+ const { root } = doctorConfig(configRoot, { requireConfigured: false });
714
+ materializeReferences(root);
715
+ const refs = new Map(referencedFiles(root).map((ref) => [ref.key, ref]));
716
+ const generated = [];
717
+ const generate = (key, bytes = 32) => {
718
+ const ref = refs.get(key);
719
+ invariant(ref, 'IDP_SECRET_REFERENCE_MISSING', `${key}未配置*_FILE路径`);
720
+ assertSafeRegularFile(ref.absolute, 0o600, { allowEmpty: true, role: key });
721
+ if (fs.statSync(ref.absolute).size > 0) {
722
+ const value = fs.readFileSync(ref.absolute, 'utf8').replace(/[\r\n]+$/u, '');
723
+ invariant(value.length > 0 && !/[\r\n]/u.test(value), 'IDP_SECRET_FORMAT_INVALID', `${key}必须是单行非空Secret`);
724
+ return value;
725
+ }
726
+ const value = crypto.randomBytes(bytes).toString('base64url');
727
+ atomicWrite(ref.absolute, `${value}\n`, 0o600);
728
+ generated.push(key);
729
+ return value;
730
+ };
731
+ generate('IDP_POSTGRES_SUPERUSER_PASSWORD_FILE');
732
+ const techPassword = generate('IDP_POSTGRES_TECH_PASSWORD_FILE');
733
+ const portalPassword = generate('IDP_POSTGRES_PORTAL_PASSWORD_FILE');
734
+ const smartgoPassword = generate('IDP_POSTGRES_SMARTGO_PASSWORD_FILE');
735
+ const operatorRef = refs.get('TECH_OPERATOR_TOKEN_FILE');
736
+ invariant(operatorRef, 'IDP_SECRET_REFERENCE_MISSING', 'TECH_OPERATOR_TOKEN_FILE未配置*_FILE路径');
737
+ assertSafeRegularFile(operatorRef.absolute, 0o600, { allowEmpty: true, role: 'TECH_OPERATOR_TOKEN_FILE' });
738
+ if (fs.statSync(operatorRef.absolute).size === 0) {
739
+ atomicWrite(operatorRef.absolute, `op0_${crypto.randomBytes(32).toString('base64url')}\n`, 0o600);
740
+ generated.push('TECH_OPERATOR_TOKEN_FILE');
741
+ } else {
742
+ const value = fs.readFileSync(operatorRef.absolute, 'utf8').replace(/[\r\n]+$/u, '');
743
+ invariant(value.length >= 32 && value.length <= 256 && /[A-Za-z]/u.test(value) && /[0-9]/u.test(value) && /^[A-Za-z0-9._~-]+$/u.test(value), 'IDP_TECH_OPERATOR_TOKEN_INVALID', 'TECH_OPERATOR_TOKEN_FILE必须是32到256位且同时包含字母和数字的安全单行Token');
744
+ }
745
+
746
+ const urls = [
747
+ ['TECH_DATABASE_URL_FILE', `postgresql://tech:${encodeURIComponent(techPassword)}@postgresql:5432/tech?schema=public`],
748
+ ['PORTAL_DATABASE_URL_FILE', `postgresql://portal:${encodeURIComponent(portalPassword)}@postgresql:5432/backstage?schema=public`],
749
+ ['DATABASE_URL_FILE', `postgresql://smartgo:${encodeURIComponent(smartgoPassword)}@postgresql:5432/smartgo?schema=public`],
750
+ ['DIRECT_DATABASE_URL_FILE', `postgresql://smartgo:${encodeURIComponent(smartgoPassword)}@postgresql:5432/smartgo?schema=public`],
751
+ ];
752
+ for (const [key, value] of urls) {
753
+ const ref = refs.get(key);
754
+ invariant(ref, 'IDP_SECRET_REFERENCE_MISSING', `${key}未配置*_FILE路径`);
755
+ assertSafeRegularFile(ref.absolute, 0o600, { allowEmpty: true, role: key });
756
+ if (fs.statSync(ref.absolute).size === 0) {
757
+ atomicWrite(ref.absolute, `${value}\n`, 0o600);
758
+ generated.push(key);
759
+ }
760
+ }
761
+ for (const key of [
762
+ 'SMARTGO_OPERATOR_TOKEN_FILE', 'PLATFORM_IDENTITY_SERVICE_TOKEN_FILE',
763
+ 'PLATFORM_IDENTITY_ACTIVATION_TOKEN_FILE', 'PLATFORM_IDENTITY_CLIENT_SECRET_FILE',
764
+ 'ARTIFACT_RENDERER_CALLBACK_TOKEN_FILE',
765
+ ]) generate(key);
766
+ const minioUserRef = refs.get('SMARTGO_MINIO_ROOT_USER_FILE');
767
+ invariant(minioUserRef, 'IDP_SECRET_REFERENCE_MISSING', 'SMARTGO_MINIO_ROOT_USER_FILE未配置*_FILE路径');
768
+ assertSafeRegularFile(minioUserRef.absolute, 0o600, { allowEmpty: true, role: 'SMARTGO_MINIO_ROOT_USER_FILE' });
769
+ let minioUser = readSingleLineReference(minioUserRef.absolute, 'SMARTGO_MINIO_ROOT_USER_FILE');
770
+ if (minioUser === null) {
771
+ minioUser = `smartgo_${crypto.randomBytes(12).toString('hex')}`;
772
+ atomicWrite(minioUserRef.absolute, `${minioUser}\n`, 0o600);
773
+ generated.push('SMARTGO_MINIO_ROOT_USER_FILE');
774
+ }
775
+ const minioPassword = generate('SMARTGO_MINIO_ROOT_PASSWORD_FILE');
776
+ const mirrorSecret = (key, value) => {
777
+ const ref = refs.get(key);
778
+ invariant(ref, 'IDP_SECRET_REFERENCE_MISSING', `${key}未配置*_FILE路径`);
779
+ assertSafeRegularFile(ref.absolute, 0o600, { allowEmpty: true, role: key });
780
+ const current = readSingleLineReference(ref.absolute, key);
781
+ if (current === null) {
782
+ atomicWrite(ref.absolute, `${value}\n`, 0o600);
783
+ generated.push(key);
784
+ } else invariant(current === value, 'IDP_SMARTGO_OBJECT_CREDENTIAL_MISMATCH', `${key}与受管MinIO凭据不一致`);
785
+ };
786
+ mirrorSecret('ARTIFACT_DELIVERY_S3_ACCESS_KEY_ID_FILE', minioUser);
787
+ mirrorSecret('WORKER_ARTIFACT_S3_ACCESS_KEY_ID_FILE', minioUser);
788
+ mirrorSecret('ARTIFACT_DELIVERY_S3_ACCESS_KEY_SECRET_FILE', minioPassword);
789
+ mirrorSecret('WORKER_ARTIFACT_S3_ACCESS_KEY_SECRET_FILE', minioPassword);
790
+ return { generated, report: doctorConfig(root, { requireConfigured: false }).report };
791
+ }
792
+
793
+ export function doctorConfig(configRoot, { requireConfigured = false, profile = 'full', allowLegacyImageLock = false } = {}) {
794
+ const root = canonicalExisting(configRoot, 'IDP_CONFIG_DIR');
795
+ assertOutsideRepositories(root, 'IDP_CONFIG_DIR');
796
+ assertMode(root, 0o700, 'IDP_CONFIG_DIR');
797
+ const { env, components } = configurationValues(root);
798
+ invariant(env.IDP_CONFIG_SCHEMA_VERSION === '1', 'IDP_CONFIG_SCHEMA_UNSUPPORTED', '只支持IDP_CONFIG_SCHEMA_VERSION=1');
799
+ if (env.IDP_OCI_REPOSITORY_ROOT && env.IDP_OCI_REPOSITORY_ROOT !== 'UNCONFIGURED') {
800
+ invariant(
801
+ /^[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[1-9][0-9]{0,4})?(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)+$/u.test(env.IDP_OCI_REPOSITORY_ROOT),
802
+ 'IDP_OCI_REPOSITORY_ROOT_INVALID',
803
+ 'IDP_OCI_REPOSITORY_ROOT必须是不含协议、凭据、tag或digest的小写OCI仓库路径',
804
+ );
805
+ }
806
+ resolveConfiguredOrasTool(root, env);
807
+ invariant(/^[a-z0-9][a-z0-9-]{2,62}$/u.test(env.IDP_INSTANCE_ID ?? ''), 'IDP_INSTANCE_INVALID', 'IDP_INSTANCE_ID必须是3到63位小写字母、数字或连字符');
808
+ invariant(['private', 'production', 'development'].includes(env.IDP_ENVIRONMENT), 'IDP_ENVIRONMENT_INVALID', 'IDP_ENVIRONMENT只能是private、production或development');
809
+ booleanValue(env, 'IDP_ALLOW_NON_GIT_SMARTGO_SOURCE');
810
+ for (const key of ['IDP_CONTAINER_UID', 'IDP_CONTAINER_GID']) {
811
+ invariant(/^[1-9][0-9]{0,9}$/u.test(env[key] ?? '') && Number(env[key]) <= 2_147_483_647, 'IDP_CONTAINER_ID_INVALID', `${key}必须是非零的十进制UID/GID`);
812
+ }
813
+ invariant(['linux/amd64', 'linux/arm64'].includes(env.IDP_RUNTIME_PLATFORM), 'IDP_RUNTIME_PLATFORM_INVALID', 'IDP_RUNTIME_PLATFORM只能是linux/amd64或linux/arm64');
814
+ if (typeof process.getuid === 'function' && typeof process.getgid === 'function') {
815
+ invariant(process.getuid() !== 0, 'IDP_ROOT_OPERATOR_FORBIDDEN', '不能使用root账号运行部署工具;0400运行时投影必须由非root部署用户创建');
816
+ invariant(Number(env.IDP_CONTAINER_UID) === process.getuid() && Number(env.IDP_CONTAINER_GID) === process.getgid(), 'IDP_CONTAINER_OWNER_MISMATCH', 'IDP_CONTAINER_UID/GID必须与当前部署用户一致,确保Linux容器可读取0400投影');
817
+ }
818
+ const active = new Set(resolveProfile(profile));
819
+ invariant(env.IDP_ENVIRONMENT !== 'production' || !active.has('smartgo') || env.IDP_ALLOW_NON_GIT_SMARTGO_SOURCE === 'false', 'IDP_NON_GIT_SOURCE_PRODUCTION_FORBIDDEN', '生产环境禁止非Git SmartGo源码发布;必须使用可追溯Git revision');
820
+ const network = validateNetworkConfiguration(env, active);
821
+ validateIdentityConfiguration(env);
822
+ if (active.has('registry')) registryPrivateScopes(env);
823
+ validateComponentConfiguration(active, env, components);
824
+ assertRequiredReferenceDeclarations(active, env, components);
825
+ const roots = ['IDP_DATA_DIR', 'IDP_BACKUP_DIR', 'IDP_LOG_DIR', 'IDP_RUNTIME_DIR', 'IDP_RESTORE_DIR'].map((key) => {
826
+ invariant(path.isAbsolute(env[key] ?? ''), 'IDP_RUNTIME_ROOT_INVALID', `${key}必须是绝对路径`);
827
+ const value = canonicalPlannedDirectory(env[key], key);
828
+ invariant(!['/', os.homedir(), '/Users', '/Users/Shared'].includes(value), 'IDP_RUNTIME_ROOT_TOO_BROAD', `${key}不能指向系统或用户根目录`);
829
+ env[key] = value;
830
+ return [key, value];
831
+ });
832
+ assertDisjointRoots([['IDP_CONFIG_DIR', root], ...roots]);
833
+ for (const [, candidate] of roots) assertOutsideRepositories(candidate, '运行数据根目录');
834
+ const unconfigured = [];
835
+ const optionalUnconfigured = [];
836
+ const techSourceCredentials = active.has('tech')
837
+ ? validateTechSourceCredentials(root, components.tech, { requireConfigured })
838
+ : { manifestReference: null, manifestPath: null, files: [] };
839
+ let imageLock;
840
+ for (const ref of referencedFiles(root)) {
841
+ const required = isReferenceRequired(ref, active, env, components);
842
+ const targetList = required ? unconfigured : optionalUnconfigured;
843
+ if (!fs.existsSync(ref.absolute)) targetList.push({ key: ref.key, reason: 'missing' });
844
+ else {
845
+ const stat = assertSafeRegularFile(ref.absolute, ref.key.includes('CERT') && !ref.key.includes('KEY') ? 0o644 : 0o600, { allowEmpty: true, role: ref.key });
846
+ if (stat.size === 0) targetList.push({ key: ref.key, reason: 'empty' });
847
+ else if (required) validateSourceReference(ref, stat);
848
+ }
849
+ if (requireConfigured && required) {
850
+ invariant(fs.existsSync(ref.absolute), 'IDP_REFERENCE_MISSING', `${ref.key}引用的文件不存在`);
851
+ assertSafeRegularFile(ref.absolute, ref.key.includes('CERT') && !ref.key.includes('KEY') ? 0o644 : 0o600, { allowEmpty: false, role: ref.key });
852
+ }
853
+ }
854
+ if (active.has('tech')) {
855
+ for (const file of techSourceCredentials.files) {
856
+ if (!fs.existsSync(file.absolute)) unconfigured.push({ key: `TECH_SOURCE_CREDENTIAL:${file.filename}`, reason: 'missing' });
857
+ else if (fs.statSync(file.absolute).size === 0) unconfigured.push({ key: `TECH_SOURCE_CREDENTIAL:${file.filename}`, reason: 'empty' });
858
+ }
859
+ }
860
+ validateDatabaseCredentialConsistency(root, active);
861
+ validateSmartGoCredentialConsistency(root, active);
862
+ if (requireConfigured) {
863
+ const imageKeys = requiredImages(active);
864
+ for (const key of imageKeys) {
865
+ invariant(/^.+@sha256:[0-9a-f]{64}$/u.test(env[key] ?? ''), 'IDP_IMAGE_NOT_PINNED', `${key}必须使用OCI digest锁定`);
866
+ }
867
+ imageLock = readImageLock(root, {
868
+ env,
869
+ requiredPlatforms: REQUIRED_IMAGE_PLATFORMS,
870
+ requiredKeys: imageKeys,
871
+ allowLegacy: allowLegacyImageLock,
872
+ }).document;
873
+ }
874
+ const imagePlaceholders = requiredImages(active).filter((key) => !/^.+@sha256:[0-9a-f]{64}$/u.test(env[key] ?? ''));
875
+ const report = {
876
+ schemaVersion: 1,
877
+ status: unconfigured.length || imagePlaceholders.length ? 'unconfigured' : 'valid',
878
+ configRoot: root,
879
+ environment: env.IDP_ENVIRONMENT,
880
+ instanceId: env.IDP_INSTANCE_ID,
881
+ profile,
882
+ host: { platform: os.platform(), arch: os.arch(), containerPlatform: env.IDP_RUNTIME_PLATFORM, appleSilicon: os.platform() === 'darwin' && os.arch() === 'arm64' },
883
+ edge: { ...network, publicHost: env.IDP_PUBLIC_HOST, allowedCidr: env.IDP_ALLOWED_CIDR },
884
+ unconfigured,
885
+ optionalUnconfigured,
886
+ imagePlaceholders,
887
+ imageLockDigest: imageLock?.digest ?? null,
888
+ digest: sha256(stableJson({
889
+ env,
890
+ components,
891
+ profile,
892
+ techSourceCredentialFiles: techSourceCredentials.files.map(({ filename }) => filename),
893
+ refs: referencedFiles(root)
894
+ .map(({ scope, key, reference }) => ({ scope, key, reference }))
895
+ .sort((left, right) => {
896
+ const leftKey = `${left.scope}\0${left.key}\0${left.reference}`;
897
+ const rightKey = `${right.scope}\0${right.key}\0${right.reference}`;
898
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
899
+ }),
900
+ })),
901
+ };
902
+ return { root, env, components, report, techSourceCredentials };
903
+ }