@aipt/idp-deploy 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +471 -0
  2. package/bin/idpctl.mjs +8 -0
  3. package/compose/edge.yaml +61 -0
  4. package/compose/flow.yaml +34 -0
  5. package/compose/portal.yaml +38 -0
  6. package/compose/postgresql.yaml +62 -0
  7. package/compose/registry.yaml +35 -0
  8. package/compose/smartgo.yaml +271 -0
  9. package/compose/tech.yaml +64 -0
  10. package/contracts/active-profile.schema.json +19 -0
  11. package/contracts/asset-lifecycle.schema.json +49 -0
  12. package/contracts/backup-generation.schema.json +60 -0
  13. package/contracts/component-runtime.schema.json +32 -0
  14. package/contracts/config-release.schema.json +33 -0
  15. package/contracts/deployment-evidence.schema.json +43 -0
  16. package/contracts/deployment-plan.schema.json +67 -0
  17. package/contracts/release-candidate.schema.json +33 -0
  18. package/contracts/release-defaults.schema.json +56 -0
  19. package/contracts/restore-candidate.schema.json +63 -0
  20. package/contracts/smartgo-component-config.schema.json +79 -0
  21. package/contracts/tech-backup-boundary.schema.json +61 -0
  22. package/contracts/tech-source-credentials.schema.json +17 -0
  23. package/deploy.sh +5 -0
  24. package/docs/restore-runbook.md +56 -0
  25. package/governance/asset-lifecycle.v1.json +79 -0
  26. package/package.json +42 -0
  27. package/release/defaults.v1.json +64 -0
  28. package/src/acceptance.mjs +127 -0
  29. package/src/bindings.mjs +518 -0
  30. package/src/cli.mjs +360 -0
  31. package/src/compose.mjs +19 -0
  32. package/src/config.mjs +903 -0
  33. package/src/delivery.mjs +123 -0
  34. package/src/errors.mjs +12 -0
  35. package/src/foundation-contracts.mjs +128 -0
  36. package/src/foundation.mjs +107 -0
  37. package/src/gitops.mjs +285 -0
  38. package/src/hash.mjs +47 -0
  39. package/src/image-lock.mjs +160 -0
  40. package/src/images.mjs +215 -0
  41. package/src/lifecycle.mjs +58 -0
  42. package/src/local-source.mjs +354 -0
  43. package/src/oci-mirror.mjs +10 -0
  44. package/src/operations.mjs +1484 -0
  45. package/src/process.mjs +46 -0
  46. package/src/profiles.mjs +41 -0
  47. package/src/release.mjs +1760 -0
  48. package/src/render.mjs +330 -0
  49. package/src/security.mjs +156 -0
  50. package/src/source-contracts.mjs +106 -0
  51. package/src/sources.mjs +78 -0
  52. package/src/workbench-projects.mjs +118 -0
@@ -0,0 +1,123 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
4
+ import { IdpError, invariant } from './errors.mjs';
5
+ import { buildContract } from './foundation-contracts.mjs';
6
+ import { hashFile, sha256, stableJson } from './hash.mjs';
7
+
8
+ const NAME = /^[a-z][a-z0-9-]{1,62}$/u;
9
+ const COMMIT = /^[0-9a-f]{40}$/u;
10
+ const REPOSITORY = /^(?:https:\/\/\S+|git@\S+:[^\s]+)$/u;
11
+
12
+ function read(candidate, role) {
13
+ invariant(path.isAbsolute(candidate ?? ''), 'IDP_DELIVERY_PATH_NOT_ABSOLUTE', `${role}必须使用绝对路径`);
14
+ const resolved = path.resolve(candidate);
15
+ const stat = fs.lstatSync(resolved, { throwIfNoEntry: false });
16
+ invariant(stat?.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && stat.size > 1 && stat.size <= 4 * 1024 * 1024, 'IDP_DELIVERY_INPUT_UNSAFE', `${role}必须是安全普通文件`);
17
+ try { return { path: resolved, digest: hashFile(resolved), value: parseYaml(fs.readFileSync(resolved, 'utf8'), { maxAliasCount: 0, uniqueKeys: true }) }; }
18
+ catch (error) { throw new IdpError('IDP_DELIVERY_DOCUMENT_INVALID', `${role}不是有效JSON或YAML`, { cause: error.message }); }
19
+ }
20
+
21
+ function component(input) {
22
+ const value = input.value;
23
+ invariant(value?.apiVersion === 'idp.company.io/v1alpha1' && value.kind === 'ComponentContract', 'IDP_DELIVERY_COMPONENT_INVALID', 'ComponentContract版本无效');
24
+ invariant(NAME.test(value.metadata?.name ?? '') && typeof value.metadata?.version === 'string', 'IDP_DELIVERY_COMPONENT_INVALID', 'ComponentContract metadata无效');
25
+ invariant(Number.isInteger(value.spec?.image?.port) && value.spec.image.port > 0 && value.spec.image.port < 65536, 'IDP_DELIVERY_COMPONENT_INVALID', 'ComponentContract端口无效');
26
+ invariant(stableJson(value.spec.image.platforms) === stableJson(['linux/amd64', 'linux/arm64']), 'IDP_DELIVERY_COMPONENT_INVALID', 'ComponentContract必须支持amd64与arm64');
27
+ return value;
28
+ }
29
+
30
+ function safeNewOutput(candidate, role) {
31
+ invariant(path.isAbsolute(candidate ?? ''), 'IDP_DELIVERY_PATH_NOT_ABSOLUTE', `${role}必须使用绝对路径`);
32
+ const target = path.resolve(candidate);
33
+ fs.mkdirSync(path.dirname(target), { recursive: true });
34
+ invariant(!fs.existsSync(target), 'IDP_DELIVERY_OUTPUT_EXISTS', `${role}已存在,拒绝覆盖用户修改`);
35
+ return target;
36
+ }
37
+
38
+ export function inspectApplication({ componentFile, environmentBindingFile }) {
39
+ const input = read(componentFile, 'ComponentContract');
40
+ const contract = component(input);
41
+ let environment = { status: 'not-required', reason: '本地开发、Workflow和交付计划不依赖Kubernetes或云资源' };
42
+ if (environmentBindingFile) {
43
+ const binding = read(environmentBindingFile, 'EnvironmentBinding').value;
44
+ invariant(binding?.kind === 'EnvironmentBinding' && binding.metadata?.consumer === contract.metadata.name, 'IDP_DELIVERY_BINDING_INVALID', 'EnvironmentBinding与应用不匹配');
45
+ const capabilities = binding.spec?.capabilities ?? {};
46
+ const required = ['cluster', 'namespace', 'imageRegistry', 'rollout'];
47
+ const missing = required.filter((name) => !capabilities[name]);
48
+ environment = { name: binding.metadata.environment, status: missing.length ? 'missing' : 'ready', missing, blocks: missing.length ? ['release-apply'] : [] };
49
+ }
50
+ return { schemaVersion: 'idp.application-inspection/v1', status: 'ready', application: contract.metadata.name, version: contract.metadata.version, componentDigest: input.digest, platforms: contract.spec.image.platforms, actions: { workflowGenerate: 'ready', buildPlan: 'ready', gitopsRender: environment.status === 'ready' ? 'ready' : 'waiting-environment', releaseApply: environment.status === 'ready' ? 'ready' : 'blocked' }, environment };
51
+ }
52
+
53
+ export function createBuildPlan({ componentFile, repository, commit, imageRepository, workflow = 'idp-delivery.yml', output, now = new Date().toISOString() }) {
54
+ const input = read(componentFile, 'ComponentContract');
55
+ const contract = component(input);
56
+ invariant(REPOSITORY.test(repository ?? ''), 'IDP_BUILD_REPOSITORY_INVALID', '源码仓库地址无效');
57
+ invariant(COMMIT.test(commit ?? ''), 'IDP_BUILD_COMMIT_INVALID', '源码提交必须是40位Git SHA');
58
+ invariant(typeof imageRepository === 'string' && /^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9._/-]+$/u.test(imageRepository), 'IDP_BUILD_IMAGE_REPOSITORY_INVALID', '镜像仓库地址无效且不能包含tag或digest');
59
+ invariant(/^[A-Za-z0-9._/-]+\.ya?ml$/u.test(workflow) && !workflow.split('/').includes('..'), 'IDP_BUILD_WORKFLOW_INVALID', 'Workflow文件名无效');
60
+ const plan = buildContract({ schemaVersion: 'idp.build-plan/v1', application: contract.metadata.name, version: contract.metadata.version, source: { repository, commit }, component: { path: input.path, digest: input.digest }, image: { repository: imageRepository, platforms: contract.spec.image.platforms }, provider: { kind: 'github-actions', workflow }, createdAt: now });
61
+ const target = safeNewOutput(output, 'Build Plan输出');
62
+ fs.writeFileSync(target, `${JSON.stringify(plan, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
63
+ return { schemaVersion: 'idp.build-plan-receipt/v1', status: 'planned', application: plan.application, plan: target, digest: plan.digest, next: '可人工检查Plan后执行build trigger;该步骤不需要Kubernetes' };
64
+ }
65
+
66
+ export function validateBuildPlanFile(planFile) {
67
+ const input = read(planFile, 'Build Plan');
68
+ const plan = input.value;
69
+ const { digest, ...payload } = plan ?? {};
70
+ invariant(plan?.schemaVersion === 'idp.build-plan/v1' && NAME.test(plan.application ?? '') && COMMIT.test(plan.source?.commit ?? '') && digest === sha256(stableJson(payload)), 'IDP_BUILD_PLAN_INVALID', 'Build Plan字段或摘要无效');
71
+ invariant(hashFile(plan.component.path) === plan.component.digest, 'IDP_BUILD_INPUT_CHANGED', 'ComponentContract已变化,请重新生成Build Plan');
72
+ return plan;
73
+ }
74
+
75
+ export function triggerBuild({ planFile, provider = 'manual', output, providerAdapter }) {
76
+ const plan = validateBuildPlanFile(planFile);
77
+ invariant(['manual', 'github-actions'].includes(provider), 'IDP_BUILD_PROVIDER_UNKNOWN', `未知构建Provider:${provider}`);
78
+ let dispatch;
79
+ if (provider === 'manual') dispatch = { provider, state: 'waiting-manual', workflow: plan.provider.workflow, ref: plan.source.commit, inputs: { buildPlanDigest: plan.digest } };
80
+ else {
81
+ invariant(typeof providerAdapter === 'function', 'IDP_BUILD_PROVIDER_NOT_CONFIGURED', 'GitHub Actions Provider尚未绑定;可使用manual生成可审计触发单,或由Portal/idp门面注入Provider');
82
+ dispatch = providerAdapter(Object.freeze({ repository: plan.source.repository, workflow: plan.provider.workflow, ref: plan.source.commit, inputs: Object.freeze({ buildPlanDigest: plan.digest }) }));
83
+ invariant(dispatch?.id && typeof dispatch.id === 'string', 'IDP_BUILD_PROVIDER_RESPONSE_INVALID', '构建Provider未返回任务ID');
84
+ }
85
+ const receipt = buildContract({ schemaVersion: 'idp.build-trigger/v1', planDigest: plan.digest, application: plan.application, provider, dispatch, triggeredAt: new Date().toISOString() });
86
+ const target = safeNewOutput(output, 'Build Trigger输出');
87
+ fs.writeFileSync(target, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
88
+ return { status: dispatch.state ?? 'triggered', receipt: target, digest: receipt.digest, next: provider === 'manual' ? '在GitHub界面运行Workflow后,以任务ID生成状态Evidence' : '执行build status查询Provider状态' };
89
+ }
90
+
91
+ export function buildStatus({ triggerFile, providerAdapter }) {
92
+ const input = read(triggerFile, 'Build Trigger');
93
+ const trigger = input.value;
94
+ const { digest, ...payload } = trigger ?? {};
95
+ invariant(trigger?.schemaVersion === 'idp.build-trigger/v1' && digest === sha256(stableJson(payload)), 'IDP_BUILD_TRIGGER_INVALID', 'Build Trigger字段或摘要无效');
96
+ if (trigger.provider === 'manual') return { schemaVersion: 'idp.build-status/v1', status: 'waiting-manual', application: trigger.application, planDigest: trigger.planDigest, next: '在GitHub Actions工作台触发并记录任务ID;尚未伪造构建成功' };
97
+ invariant(typeof providerAdapter === 'function', 'IDP_BUILD_PROVIDER_NOT_CONFIGURED', '查询GitHub Actions需要由Portal/idp门面绑定Provider');
98
+ return { schemaVersion: 'idp.build-status/v1', application: trigger.application, planDigest: trigger.planDigest, ...providerAdapter(trigger.dispatch.id) };
99
+ }
100
+
101
+ export function generateGithubWorkflow({ output }) {
102
+ const target = safeNewOutput(output, 'Workflow输出');
103
+ const document = {
104
+ name: 'IDP 构建与交付',
105
+ on: { push: { branches: ['main'] }, workflow_dispatch: { inputs: { buildPlanDigest: { description: '不可变Build Plan摘要', required: false, type: 'string' } } } },
106
+ permissions: { contents: 'read', packages: 'write', 'id-token': 'write' },
107
+ jobs: { build: { 'runs-on': 'ubuntu-latest', steps: [{ uses: 'actions/checkout@v4' }, { name: '验证工程', run: 'npm ci && npm test' }, { name: '构建双架构OCI镜像', run: 'echo "由已绑定的OCI Builder能力执行;禁止在Workflow保存长期云密钥"' }] } },
108
+ };
109
+ fs.writeFileSync(target, stringifyYaml(document), { mode: 0o644, flag: 'wx' });
110
+ return { schemaVersion: 'idp.workflow-generation/v1', status: 'generated', target, digest: hashFile(target), provider: 'github-actions' };
111
+ }
112
+
113
+ export function verifyGithubWorkflow({ workflowFile }) {
114
+ const input = read(workflowFile, 'GitHub Workflow');
115
+ const workflow = input.value;
116
+ const triggers = workflow.on ?? workflow.true;
117
+ invariant(workflow?.permissions?.contents === 'read' && workflow.permissions.packages === 'write' && workflow.permissions['id-token'] === 'write', 'IDP_WORKFLOW_PERMISSION_INVALID', 'Workflow必须使用最小权限并启用OIDC');
118
+ invariant(triggers?.push || triggers?.workflow_dispatch, 'IDP_WORKFLOW_TRIGGER_INVALID', 'Workflow必须声明push或workflow_dispatch触发器');
119
+ const serialized = stableJson(workflow);
120
+ invariant(!/(?:access[_-]?key|secret[_-]?key|password|private[_-]?key)\s*[:=]\s*["']?[^${\s]/iu.test(serialized), 'IDP_WORKFLOW_SECRET_INVALID', 'Workflow疑似内联长期凭据');
121
+ invariant(serialized.includes('actions/checkout@v4'), 'IDP_WORKFLOW_STEP_INVALID', 'Workflow缺少固定主版本的checkout步骤');
122
+ return { schemaVersion: 'idp.workflow-verification/v1', status: 'valid', workflow: input.path, digest: input.digest, triggers: Object.keys(triggers).sort(), kubernetesRequired: false };
123
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,12 @@
1
+ export class IdpError extends Error {
2
+ constructor(code, message, details = undefined) {
3
+ super(message);
4
+ this.name = 'IdpError';
5
+ this.code = code;
6
+ this.details = details;
7
+ }
8
+ }
9
+
10
+ export function invariant(condition, code, message, details) {
11
+ if (!condition) throw new IdpError(code, message, details);
12
+ }
@@ -0,0 +1,128 @@
1
+ import { IdpError, invariant } from './errors.mjs';
2
+ import { sha256, stableJson } from './hash.mjs';
3
+
4
+ const DIGEST = /^sha256:[0-9a-f]{64}$/u;
5
+ const NAME = /^[a-z][a-z0-9-]{1,62}$/u;
6
+ const ENVIRONMENT_REF = /^environment:[a-z][a-z0-9-]*(?:\.[a-z0-9-]+)*@v[1-9][0-9]*$/u;
7
+ const COMMIT = /^[0-9a-f]{40}$/u;
8
+ const PINNED_IMAGE = /^\S+@sha256:[0-9a-f]{64}$/u;
9
+
10
+ function exactKeys(value, expected, code, role) {
11
+ invariant(value && typeof value === 'object' && !Array.isArray(value), code, `${role}必须是对象`);
12
+ invariant(stableJson(Object.keys(value).sort()) === stableJson([...expected].sort()), code, `${role}字段集合无效`);
13
+ }
14
+
15
+ function validTime(value) {
16
+ const parsed = typeof value === 'string' ? Date.parse(value) : Number.NaN;
17
+ return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
18
+ }
19
+
20
+ function validateDigestList(values, code, role, { minItems = 0 } = {}) {
21
+ invariant(Array.isArray(values) && values.length >= minItems, code, `${role}数量无效`);
22
+ invariant(values.every((value) => DIGEST.test(value)), code, `${role}包含无效摘要`);
23
+ invariant(stableJson(values) === stableJson([...new Set(values)].sort()), code, `${role}必须唯一并按字典序排列`);
24
+ }
25
+
26
+ function verifySelfDigest(document, code) {
27
+ const { digest, ...payload } = document;
28
+ invariant(DIGEST.test(digest ?? '') && digest === sha256(stableJson(payload)), code, '合同整体摘要不匹配');
29
+ }
30
+
31
+ export function buildContract(payload) {
32
+ return { ...payload, digest: sha256(stableJson(payload)) };
33
+ }
34
+
35
+ export function validateConfigRelease(document) {
36
+ const code = 'IDP_CONFIG_RELEASE_INVALID';
37
+ exactKeys(document, ['schemaVersion', 'application', 'environmentRef', 'reloadMode', 'source', 'files', 'secretBindingDigests', 'createdAt', 'digest'], code, 'ConfigRelease');
38
+ invariant(document.schemaVersion === 'idp.config-release/v1', code, 'ConfigRelease版本无效');
39
+ invariant(NAME.test(document.application ?? ''), code, 'ConfigRelease应用名无效');
40
+ invariant(ENVIRONMENT_REF.test(document.environmentRef ?? ''), code, 'ConfigRelease环境引用无效');
41
+ invariant(['restart', 'watch', 'dynamic-provider'].includes(document.reloadMode), code, 'ConfigRelease reloadMode无效');
42
+ exactKeys(document.source, ['kind', 'ref', 'digest'], code, 'ConfigRelease source');
43
+ invariant(['git', 'oci', 'config-provider'].includes(document.source.kind), code, 'ConfigRelease source.kind无效');
44
+ invariant(typeof document.source.ref === 'string' && document.source.ref.length > 0 && !/\s/u.test(document.source.ref), code, 'ConfigRelease source.ref无效');
45
+ invariant(DIGEST.test(document.source.digest ?? ''), code, 'ConfigRelease source.digest无效');
46
+ invariant(Array.isArray(document.files), code, 'ConfigRelease files必须是数组');
47
+ const paths = [];
48
+ for (const entry of document.files) {
49
+ exactKeys(entry, ['path', 'digest', 'sensitive'], code, 'ConfigRelease file');
50
+ invariant(typeof entry.path === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/u.test(entry.path) && !entry.path.split('/').includes('..'), code, 'ConfigRelease file.path无效');
51
+ invariant(DIGEST.test(entry.digest ?? '') && entry.sensitive === false, code, 'ConfigRelease只能登记非敏感配置摘要');
52
+ paths.push(entry.path);
53
+ }
54
+ invariant(stableJson(paths) === stableJson([...new Set(paths)].sort()), code, 'ConfigRelease files必须按path唯一排序');
55
+ validateDigestList(document.secretBindingDigests, code, 'ConfigRelease secretBindingDigests');
56
+ invariant(validTime(document.createdAt), code, 'ConfigRelease createdAt无效');
57
+ verifySelfDigest(document, code);
58
+ return document;
59
+ }
60
+
61
+ export function validateReleaseCandidate(document) {
62
+ const code = 'IDP_RELEASE_CANDIDATE_INVALID';
63
+ exactKeys(document, ['schemaVersion', 'application', 'version', 'source', 'componentContractDigest', 'image', 'configReleaseDigest', 'acceptanceEvidenceDigests', 'targetEnvironmentRef', 'rolloutStrategy', 'createdAt', 'digest'], code, 'ReleaseCandidate');
64
+ invariant(document.schemaVersion === 'idp.release-candidate/v1', code, 'ReleaseCandidate版本无效');
65
+ invariant(NAME.test(document.application ?? '') && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(document.version ?? ''), code, 'ReleaseCandidate应用名或版本无效');
66
+ exactKeys(document.source, ['repository', 'commit', 'digest'], code, 'ReleaseCandidate source');
67
+ invariant(/^https:\/\/\S+$/u.test(document.source.repository ?? '') || /^git@\S+:[^\s]+$/u.test(document.source.repository ?? ''), code, 'ReleaseCandidate repository无效');
68
+ invariant(COMMIT.test(document.source.commit ?? '') && DIGEST.test(document.source.digest ?? ''), code, 'ReleaseCandidate source身份无效');
69
+ invariant(DIGEST.test(document.componentContractDigest ?? ''), code, 'ReleaseCandidate componentContractDigest无效');
70
+ exactKeys(document.image, ['ref', 'digest', 'platforms'], code, 'ReleaseCandidate image');
71
+ invariant(PINNED_IMAGE.test(document.image.ref ?? '') && DIGEST.test(document.image.digest ?? '') && document.image.ref.endsWith(`@${document.image.digest}`), code, 'ReleaseCandidate镜像必须使用匹配的OCI digest引用');
72
+ invariant(stableJson(document.image.platforms) === stableJson(['linux/amd64', 'linux/arm64']), code, 'ReleaseCandidate镜像必须证明amd64与arm64');
73
+ invariant(document.configReleaseDigest === null || DIGEST.test(document.configReleaseDigest ?? ''), code, 'ReleaseCandidate configReleaseDigest无效');
74
+ validateDigestList(document.acceptanceEvidenceDigests, code, 'ReleaseCandidate acceptanceEvidenceDigests', { minItems: 1 });
75
+ invariant(ENVIRONMENT_REF.test(document.targetEnvironmentRef ?? ''), code, 'ReleaseCandidate目标环境无效');
76
+ invariant(['rolling', 'canary', 'blue-green'].includes(document.rolloutStrategy), code, 'ReleaseCandidate rolloutStrategy无效');
77
+ invariant(validTime(document.createdAt), code, 'ReleaseCandidate createdAt无效');
78
+ verifySelfDigest(document, code);
79
+ return document;
80
+ }
81
+
82
+ export function validateDeploymentEvidence(document) {
83
+ const code = 'IDP_DEPLOYMENT_EVIDENCE_INVALID';
84
+ exactKeys(document, ['schemaVersion', 'planDigest', 'releaseCandidateDigest', 'runtime', 'environmentRef', 'repositoryState', 'rollout', 'resources', 'checks', 'observedAt', 'digest'], code, 'DeploymentEvidence');
85
+ invariant(document.schemaVersion === 'idp.deployment-evidence/v1', code, 'DeploymentEvidence版本无效');
86
+ invariant(DIGEST.test(document.planDigest ?? '') && DIGEST.test(document.releaseCandidateDigest ?? ''), code, 'DeploymentEvidence计划或候选摘要无效');
87
+ invariant(['compose', 'kubernetes-gitops'].includes(document.runtime), code, 'DeploymentEvidence runtime无效');
88
+ invariant(ENVIRONMENT_REF.test(document.environmentRef ?? ''), code, 'DeploymentEvidence环境引用无效');
89
+ if (document.runtime === 'compose') invariant(document.repositoryState === null, code, 'Compose Evidence不能伪造GitOps Repository State');
90
+ else {
91
+ exactKeys(document.repositoryState, ['repository', 'commit'], code, 'DeploymentEvidence repositoryState');
92
+ invariant(typeof document.repositoryState.repository === 'string' && document.repositoryState.repository.length > 0 && COMMIT.test(document.repositoryState.commit ?? ''), code, 'DeploymentEvidence Repository State无效');
93
+ }
94
+ exactKeys(document.rollout, ['strategy', 'status', 'stableImageDigest'], code, 'DeploymentEvidence rollout');
95
+ invariant(['rolling', 'canary', 'blue-green'].includes(document.rollout.strategy) && ['healthy', 'degraded', 'aborted'].includes(document.rollout.status) && DIGEST.test(document.rollout.stableImageDigest ?? ''), code, 'DeploymentEvidence rollout无效');
96
+ invariant(Array.isArray(document.resources), code, 'DeploymentEvidence resources必须是数组');
97
+ const resourceKeys = [];
98
+ for (const resource of document.resources) {
99
+ exactKeys(resource, ['apiVersion', 'kind', 'namespace', 'name', 'uid', 'imageDigest'], code, 'DeploymentEvidence resource');
100
+ invariant([resource.apiVersion, resource.kind, resource.namespace, resource.name, resource.uid].every((value) => typeof value === 'string' && value.length > 0), code, 'DeploymentEvidence resource身份无效');
101
+ invariant(resource.imageDigest === null || DIGEST.test(resource.imageDigest ?? ''), code, 'DeploymentEvidence resource.imageDigest无效');
102
+ resourceKeys.push(`${resource.apiVersion}/${resource.kind}/${resource.namespace}/${resource.name}`);
103
+ }
104
+ invariant(stableJson(resourceKeys) === stableJson([...new Set(resourceKeys)].sort()), code, 'DeploymentEvidence resources必须唯一排序');
105
+ invariant(Array.isArray(document.checks) && document.checks.length > 0, code, 'DeploymentEvidence checks不能为空');
106
+ const checkNames = [];
107
+ for (const check of document.checks) {
108
+ exactKeys(check, ['name', 'status', 'evidenceDigest'], code, 'DeploymentEvidence check');
109
+ invariant(NAME.test(check.name ?? '') && ['passed', 'failed'].includes(check.status) && DIGEST.test(check.evidenceDigest ?? ''), code, 'DeploymentEvidence check无效');
110
+ checkNames.push(check.name);
111
+ }
112
+ invariant(stableJson(checkNames) === stableJson([...new Set(checkNames)].sort()), code, 'DeploymentEvidence checks必须唯一排序');
113
+ invariant(validTime(document.observedAt), code, 'DeploymentEvidence observedAt无效');
114
+ verifySelfDigest(document, code);
115
+ return document;
116
+ }
117
+
118
+ export const FOUNDATION_CONTRACT_VALIDATORS = Object.freeze({
119
+ 'config-release': validateConfigRelease,
120
+ 'release-candidate': validateReleaseCandidate,
121
+ 'deployment-evidence': validateDeploymentEvidence,
122
+ });
123
+
124
+ export function validateFoundationContract(kind, document) {
125
+ const validator = FOUNDATION_CONTRACT_VALIDATORS[kind];
126
+ if (!validator) throw new IdpError('IDP_CONTRACT_KIND_UNKNOWN', `未知基座合同:${kind}`);
127
+ return validator(document);
128
+ }
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { IdpError, invariant } from './errors.mjs';
5
+ import { hashFile, sha256, stableJson } from './hash.mjs';
6
+
7
+ function command(command, args, { required = true } = {}) {
8
+ const result = spawnSync(command, args, { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
9
+ if (required && (result.error || result.status !== 0)) throw new IdpError('IDP_FOUNDATION_COMMAND_FAILED', `${command}检查失败`, { stderr: result.stderr?.trim() });
10
+ return result;
11
+ }
12
+
13
+ function kubectl(context, args, options) {
14
+ return command('kubectl', ['--context', context, ...args], options);
15
+ }
16
+
17
+ function commandAvailable(name) {
18
+ return spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', 'foundation-doctor', name], { encoding: 'utf8' }).status === 0;
19
+ }
20
+
21
+ export function doctorFoundation({ context = 'kind-idp-foundation' } = {}) {
22
+ const tools = Object.fromEntries(['docker', 'kubectl', 'kind', 'helm', 'git'].map((name) => [name, commandAvailable(name)]));
23
+ const contextResult = tools.kubectl ? command('kubectl', ['config', 'get-contexts', context, '-o', 'name'], { required: false }) : { status: 1, stdout: '' };
24
+ const nodeResult = contextResult.status === 0 ? kubectl(context, ['get', 'nodes', '-o', 'json'], { required: false }) : { status: 1, stdout: '' };
25
+ let nodes = [];
26
+ if (nodeResult.status === 0) {
27
+ const document = JSON.parse(nodeResult.stdout);
28
+ nodes = document.items.map((node) => ({ name: node.metadata.name, version: node.status.nodeInfo.kubeletVersion, ready: node.status.conditions.some((entry) => entry.type === 'Ready' && entry.status === 'True') }));
29
+ }
30
+ const dockerInfo = tools.docker ? command('docker', ['info', '--format', '{{json .}}'], { required: false }) : { status: 1 };
31
+ let dockerMemoryBytes = null;
32
+ if (dockerInfo.status === 0) dockerMemoryBytes = JSON.parse(dockerInfo.stdout).MemTotal;
33
+ const cloudCredentialKeys = ['ALIBABA_CLOUD_ACCESS_KEY_ID', 'ALIBABA_CLOUD_ACCESS_KEY_SECRET', 'ALICLOUD_ACCESS_KEY', 'ALICLOUD_SECRET_KEY'];
34
+ const cloudConfigured = cloudCredentialKeys.some((key) => Boolean(process.env[key]));
35
+ return {
36
+ schemaVersion: 'idp.foundation-doctor/v1', status: Object.values(tools).every(Boolean) && nodes.length > 0 && nodes.every((node) => node.ready) ? 'ready' : 'degraded',
37
+ context, tools, nodes, resources: { dockerMemoryBytes, localProfile: 'gitops-core', heavyObservabilityRecommendedTarget: dockerMemoryBytes !== null && dockerMemoryBytes < 12 * 1024 ** 3 ? 'aliyun-ack' : 'local-or-aliyun-ack' },
38
+ aliyun: { credentialsConfigured: cloudConfigured, action: cloudConfigured ? '可验证已有ACK context;仍不自动创建云资源' : '未配置凭据,只渲染production profile,不创建云资源' },
39
+ };
40
+ }
41
+
42
+ const REQUIRED_WORKLOADS = [
43
+ ['Deployment', 'argocd', 'argocd-server'],
44
+ ['Deployment', 'argocd', 'argocd-repo-server'],
45
+ ['StatefulSet', 'argocd', 'argocd-application-controller'],
46
+ ['Deployment', 'argocd', 'argocd-applicationset-controller'],
47
+ ['Deployment', 'argo-rollouts', 'argo-rollouts'],
48
+ ['Deployment', 'cert-manager', 'cert-manager'],
49
+ ['Deployment', 'cert-manager', 'cert-manager-webhook'],
50
+ ['Deployment', 'external-secrets', 'external-secrets'],
51
+ ['Deployment', 'external-dns', 'external-dns'],
52
+ ['Deployment', 'ingress-nginx', 'ingress-nginx-controller'],
53
+ ['DaemonSet', 'observability', 'opentelemetry-collector-agent'],
54
+ ];
55
+
56
+ function workloadStatus(context, [kind, namespace, name]) {
57
+ const result = kubectl(context, ['get', kind, name, '-n', namespace, '-o', 'json'], { required: false });
58
+ if (result.status !== 0) return { kind, namespace, name, status: 'missing' };
59
+ const resource = JSON.parse(result.stdout);
60
+ const desired = kind === 'DaemonSet' ? resource.status.desiredNumberScheduled : resource.spec.replicas;
61
+ const ready = kind === 'DaemonSet' ? resource.status.numberReady ?? 0 : resource.status.readyReplicas ?? 0;
62
+ return { kind, namespace, name, desired, ready, status: desired > 0 && ready === desired ? 'healthy' : 'degraded' };
63
+ }
64
+
65
+ export function verifyFoundation({ context = 'kind-idp-foundation' } = {}) {
66
+ const workloads = REQUIRED_WORKLOADS.map((entry) => workloadStatus(context, entry));
67
+ const crds = ['applications.argoproj.io', 'applicationsets.argoproj.io', 'rollouts.argoproj.io', 'certificates.cert-manager.io', 'externalsecrets.external-secrets.io'];
68
+ const crdStatuses = crds.map((name) => ({ name, installed: kubectl(context, ['get', 'crd', name, '-o', 'name'], { required: false }).status === 0 }));
69
+ const applicationsResult = kubectl(context, ['get', 'applications.argoproj.io', '-n', 'argocd', '-o', 'json'], { required: false });
70
+ const applications = applicationsResult.status === 0 ? JSON.parse(applicationsResult.stdout).items.map((application) => ({ name: application.metadata.name, sync: application.status?.sync?.status ?? 'Unknown', health: application.status?.health?.status ?? 'Unknown', revision: application.status?.sync?.revision ?? null })).sort((left, right) => left.name.localeCompare(right.name)) : [];
71
+ const failures = [...workloads.filter((entry) => entry.status !== 'healthy').map((entry) => `${entry.namespace}/${entry.name}:${entry.status}`), ...crdStatuses.filter((entry) => !entry.installed).map((entry) => `crd/${entry.name}:missing`), ...applications.filter((entry) => entry.sync !== 'Synced' || !['Healthy', 'Progressing'].includes(entry.health)).map((entry) => `application/${entry.name}:${entry.sync}/${entry.health}`)];
72
+ return { schemaVersion: 'idp.foundation-verification/v1', status: failures.length === 0 ? 'healthy' : 'degraded', context, workloads, crds: crdStatuses, applications, failures, evidenceDigest: sha256(stableJson({ context, workloads, crdStatuses, applications })) };
73
+ }
74
+
75
+ function validLocalUrl(value, role) {
76
+ let parsed;
77
+ try { parsed = new URL(value); } catch { throw new IdpError('IDP_FOUNDATION_DIRECTORY_INVALID', `${role}不是有效URL`); }
78
+ invariant(['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password, 'IDP_FOUNDATION_DIRECTORY_INVALID', `${role}必须是不含凭据的HTTP(S) URL`);
79
+ return parsed.toString();
80
+ }
81
+
82
+ export function syncFoundationDirectory({ configRoot, argoUrl = 'http://127.0.0.1:9081/', rolloutsUrl = 'http://127.0.0.1:9082/rollouts/', registryUrl = 'http://127.0.0.1:4873/', now = new Date().toISOString() }) {
83
+ const snapshotRoot = path.join(configRoot, 'components', 'portal', 'snapshots');
84
+ const target = path.join(snapshotRoot, 'foundation-directory.json');
85
+ fs.mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 });
86
+ const preimage = fs.lstatSync(target, { throwIfNoEntry: false });
87
+ if (preimage) {
88
+ invariant(preimage.isFile() && !preimage.isSymbolicLink() && preimage.nlink === 1, 'IDP_FOUNDATION_DIRECTORY_UNSAFE', '现有基座目录不是安全普通文件');
89
+ const backupRoot = path.join(configRoot, 'preimages', 'foundation-directory');
90
+ fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
91
+ const backup = path.join(backupRoot, `${Date.now()}-${hashFile(target).slice(7, 19)}.json`);
92
+ fs.copyFileSync(target, backup, fs.constants.COPYFILE_EXCL);
93
+ fs.chmodSync(backup, 0o600);
94
+ }
95
+ const document = {
96
+ schemaVersion: 'portal.foundation-directory/v1', generatedAt: now,
97
+ systems: [
98
+ { id: 'gitops', title: 'GitOps 发布', description: 'Argo CD 应用差异、同步和健康状态', uiUrl: validLocalUrl(argoUrl, 'Argo CD URL'), healthUrl: new URL('healthz', validLocalUrl(argoUrl, 'Argo CD URL')).toString(), owner: 'idp-deploy' },
99
+ { id: 'rollouts', title: '渐进发布', description: 'Argo Rollouts 金丝雀与蓝绿发布状态', uiUrl: validLocalUrl(rolloutsUrl, 'Rollouts URL'), owner: 'idp-deploy' },
100
+ { id: 'registry', title: '本机制品仓', description: 'Verdaccio 包制品与版本', uiUrl: validLocalUrl(registryUrl, 'Registry URL'), owner: 'platform-runtime' },
101
+ ],
102
+ };
103
+ const temporary = `${target}.tmp-${process.pid}`;
104
+ fs.writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
105
+ fs.renameSync(temporary, target);
106
+ return { schemaVersion: 'idp.foundation-directory-receipt/v1', status: 'synced', target, digest: hashFile(target), systems: document.systems.map((entry) => entry.id) };
107
+ }