@aipt/idp-deploy 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +471 -0
- package/bin/idpctl.mjs +8 -0
- package/compose/edge.yaml +61 -0
- package/compose/flow.yaml +34 -0
- package/compose/portal.yaml +38 -0
- package/compose/postgresql.yaml +62 -0
- package/compose/registry.yaml +35 -0
- package/compose/smartgo.yaml +271 -0
- package/compose/tech.yaml +64 -0
- package/contracts/active-profile.schema.json +19 -0
- package/contracts/asset-lifecycle.schema.json +49 -0
- package/contracts/backup-generation.schema.json +60 -0
- package/contracts/component-runtime.schema.json +32 -0
- package/contracts/config-release.schema.json +33 -0
- package/contracts/deployment-evidence.schema.json +43 -0
- package/contracts/deployment-plan.schema.json +67 -0
- package/contracts/release-candidate.schema.json +33 -0
- package/contracts/release-defaults.schema.json +56 -0
- package/contracts/restore-candidate.schema.json +63 -0
- package/contracts/smartgo-component-config.schema.json +79 -0
- package/contracts/tech-backup-boundary.schema.json +61 -0
- package/contracts/tech-source-credentials.schema.json +17 -0
- package/deploy.sh +5 -0
- package/docs/restore-runbook.md +56 -0
- package/governance/asset-lifecycle.v1.json +79 -0
- package/package.json +42 -0
- package/release/defaults.v1.json +64 -0
- package/src/acceptance.mjs +127 -0
- package/src/bindings.mjs +518 -0
- package/src/cli.mjs +360 -0
- package/src/compose.mjs +19 -0
- package/src/config.mjs +903 -0
- package/src/delivery.mjs +123 -0
- package/src/errors.mjs +12 -0
- package/src/foundation-contracts.mjs +128 -0
- package/src/foundation.mjs +107 -0
- package/src/gitops.mjs +285 -0
- package/src/hash.mjs +47 -0
- package/src/image-lock.mjs +160 -0
- package/src/images.mjs +215 -0
- package/src/lifecycle.mjs +58 -0
- package/src/local-source.mjs +354 -0
- package/src/oci-mirror.mjs +10 -0
- package/src/operations.mjs +1484 -0
- package/src/process.mjs +46 -0
- package/src/profiles.mjs +41 -0
- package/src/release.mjs +1760 -0
- package/src/render.mjs +330 -0
- package/src/security.mjs +156 -0
- package/src/source-contracts.mjs +106 -0
- package/src/sources.mjs +78 -0
- package/src/workbench-projects.mjs +118 -0
package/src/gitops.mjs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
import { IdpError, invariant } from './errors.mjs';
|
|
6
|
+
import { hashFile, inventoryDirectory, sha256, stableJson } from './hash.mjs';
|
|
7
|
+
import { buildContract, validateReleaseCandidate } from './foundation-contracts.mjs';
|
|
8
|
+
|
|
9
|
+
const NAME = /^[a-z][a-z0-9-]{1,62}$/u;
|
|
10
|
+
const ENVIRONMENT = /^[a-z][a-z0-9-]{1,62}$/u;
|
|
11
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
|
|
12
|
+
const PINNED_IMAGE = /^([^\s@]+)@(sha256:[0-9a-f]{64})$/u;
|
|
13
|
+
|
|
14
|
+
function exactObject(value, fields, role) {
|
|
15
|
+
invariant(value && typeof value === 'object' && !Array.isArray(value), 'IDP_GITOPS_INPUT_INVALID', `${role}必须是对象`);
|
|
16
|
+
invariant(stableJson(Object.keys(value).sort()) === stableJson([...fields].sort()), 'IDP_GITOPS_INPUT_INVALID', `${role}字段集合无效`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readDocumentFile(candidate, role) {
|
|
20
|
+
invariant(path.isAbsolute(candidate ?? ''), 'IDP_GITOPS_PATH_NOT_ABSOLUTE', `${role}必须使用绝对路径`);
|
|
21
|
+
const resolved = path.resolve(candidate);
|
|
22
|
+
const stat = fs.lstatSync(resolved, { throwIfNoEntry: false });
|
|
23
|
+
invariant(stat?.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && stat.size > 1 && stat.size <= 4 * 1024 * 1024, 'IDP_GITOPS_INPUT_UNSAFE', `${role}必须是1 byte到4 MiB的普通非链接文件`);
|
|
24
|
+
try { return { path: resolved, digest: hashFile(resolved), value: parseYaml(fs.readFileSync(resolved, 'utf8'), { maxAliasCount: 0, uniqueKeys: true }) }; }
|
|
25
|
+
catch (error) { throw new IdpError('IDP_GITOPS_DOCUMENT_INVALID', `${role}不是有效JSON或YAML`, { cause: error.message }); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validateComponent(document) {
|
|
29
|
+
exactObject(document, ['apiVersion', 'kind', 'metadata', 'spec'], 'ComponentContract');
|
|
30
|
+
invariant(document.apiVersion === 'idp.company.io/v1alpha1' && document.kind === 'ComponentContract', 'IDP_GITOPS_COMPONENT_INVALID', 'ComponentContract版本或kind无效');
|
|
31
|
+
invariant(NAME.test(document.metadata?.name ?? '') && typeof document.metadata.version === 'string', 'IDP_GITOPS_COMPONENT_INVALID', 'ComponentContract metadata无效');
|
|
32
|
+
const image = document.spec?.image;
|
|
33
|
+
invariant(Number.isInteger(image?.port) && image.port > 0 && image.port < 65536, 'IDP_GITOPS_COMPONENT_INVALID', 'ComponentContract image.port无效');
|
|
34
|
+
invariant(stableJson(image.platforms) === stableJson(['linux/amd64', 'linux/arm64']), 'IDP_GITOPS_COMPONENT_INVALID', 'ComponentContract必须支持amd64与arm64');
|
|
35
|
+
invariant(typeof document.spec?.health?.liveness === 'string' && document.spec.health.liveness.startsWith('/'), 'IDP_GITOPS_COMPONENT_INVALID', 'ComponentContract liveness无效');
|
|
36
|
+
invariant(typeof document.spec?.health?.readiness === 'string' && document.spec.health.readiness.startsWith('/'), 'IDP_GITOPS_COMPONENT_INVALID', 'ComponentContract readiness无效');
|
|
37
|
+
invariant(document.spec?.runtime?.stateless === true, 'IDP_GITOPS_COMPONENT_INVALID', '首批GitOps Renderer只接收无状态应用');
|
|
38
|
+
invariant(Number.isInteger(document.spec.runtime.runAsUser) && document.spec.runtime.runAsUser > 0, 'IDP_GITOPS_COMPONENT_INVALID', 'ComponentContract必须声明非root runAsUser');
|
|
39
|
+
invariant(Number.isInteger(document.spec.runtime.runAsGroup) && document.spec.runtime.runAsGroup > 0, 'IDP_GITOPS_COMPONENT_INVALID', 'ComponentContract必须声明非root runAsGroup');
|
|
40
|
+
invariant(document.spec.runtime.args === undefined || (Array.isArray(document.spec.runtime.args) && document.spec.runtime.args.every((entry) => typeof entry === 'string' && entry.length > 0)), 'IDP_GITOPS_COMPONENT_INVALID', 'ComponentContract runtime.args无效');
|
|
41
|
+
return document;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validateUrl(value, role) {
|
|
45
|
+
let parsed;
|
|
46
|
+
try { parsed = new URL(value); } catch { throw new IdpError('IDP_GITOPS_BINDING_INVALID', `${role}不是有效URL`); }
|
|
47
|
+
invariant(['https:', 'http:'].includes(parsed.protocol) && !parsed.username && !parsed.password, 'IDP_GITOPS_BINDING_INVALID', `${role}只能使用不含凭据的HTTP(S) URL`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function validateEnvironmentBinding(document) {
|
|
51
|
+
const forbidden = /(?:password|passwd|secretvalue|token|privatekey|accesskey|kubeconfig)/iu;
|
|
52
|
+
const inspect = (value, keyPath = '') => {
|
|
53
|
+
if (Array.isArray(value)) return value.forEach((entry, index) => inspect(entry, `${keyPath}[${index}]`));
|
|
54
|
+
if (!value || typeof value !== 'object') return;
|
|
55
|
+
for (const [key, child] of Object.entries(value)) {
|
|
56
|
+
invariant(!forbidden.test(key), 'IDP_GITOPS_BINDING_INVALID', `EnvironmentBinding包含敏感字段:${keyPath}${key}`);
|
|
57
|
+
inspect(child, `${keyPath}${key}.`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
inspect(document);
|
|
61
|
+
invariant(document?.apiVersion === 'infrastructure.bench.dev/v1' && document.kind === 'EnvironmentBinding', 'IDP_GITOPS_BINDING_INVALID', 'EnvironmentBinding版本或kind无效');
|
|
62
|
+
const metadata = document.metadata;
|
|
63
|
+
invariant(typeof metadata?.id === 'string' && typeof metadata?.consumer === 'string' && ENVIRONMENT.test(metadata?.environment ?? ''), 'IDP_GITOPS_BINDING_INVALID', 'EnvironmentBinding metadata无效');
|
|
64
|
+
const capabilities = document.spec?.capabilities;
|
|
65
|
+
for (const key of ['cluster', 'namespace', 'dns', 'ingress', 'tls', 'secretStore', 'imageRegistry', 'rollout', 'observability']) {
|
|
66
|
+
invariant(capabilities?.[key] && typeof capabilities[key] === 'object', 'IDP_GITOPS_BINDING_INVALID', `EnvironmentBinding缺少${key}能力`);
|
|
67
|
+
}
|
|
68
|
+
invariant(capabilities.cluster.kind === 'kubernetes.cluster' && typeof capabilities.cluster.clusterRef === 'string', 'IDP_GITOPS_BINDING_INVALID', '集群能力无效');
|
|
69
|
+
validateUrl(capabilities.cluster.server, 'cluster.server');
|
|
70
|
+
invariant(capabilities.namespace.kind === 'kubernetes.namespace' && NAME.test(capabilities.namespace.name ?? '') && capabilities.namespace.isolation === 'namespace', 'IDP_GITOPS_BINDING_INVALID', 'Namespace能力无效');
|
|
71
|
+
invariant(capabilities.dns.kind === 'dns.record' && capabilities.dns.controller === 'external-dns' && typeof capabilities.dns.hostname === 'string', 'IDP_GITOPS_BINDING_INVALID', 'DNS能力无效');
|
|
72
|
+
invariant(capabilities.ingress.kind === 'network.ingress' && capabilities.ingress.controller === 'ingress-nginx' && typeof capabilities.ingress.className === 'string' && ['public', 'internal'].includes(capabilities.ingress.exposure), 'IDP_GITOPS_BINDING_INVALID', 'Ingress能力无效');
|
|
73
|
+
invariant(capabilities.tls.kind === 'tls.certificate' && capabilities.tls.controller === 'cert-manager' && ['Issuer', 'ClusterIssuer'].includes(capabilities.tls.issuerKind), 'IDP_GITOPS_BINDING_INVALID', 'TLS能力无效');
|
|
74
|
+
invariant(capabilities.secretStore.kind === 'secret-store.external' && capabilities.secretStore.controller === 'external-secrets', 'IDP_GITOPS_BINDING_INVALID', 'Secret Store能力无效');
|
|
75
|
+
invariant(capabilities.imageRegistry.kind === 'artifact.oci-registry' && capabilities.imageRegistry.immutableReferenceRequired === true, 'IDP_GITOPS_BINDING_INVALID', '镜像仓能力必须要求不可变引用');
|
|
76
|
+
invariant(capabilities.rollout.kind === 'deployment.progressive-delivery' && capabilities.rollout.controller === 'argo-rollouts' && ['canary', 'blue-green'].includes(capabilities.rollout.strategy), 'IDP_GITOPS_BINDING_INVALID', 'Rollout能力无效');
|
|
77
|
+
invariant(capabilities.observability.kind === 'observability.telemetry', 'IDP_GITOPS_BINDING_INVALID', '观测能力无效');
|
|
78
|
+
validateUrl(capabilities.observability.otlpEndpoint, 'observability.otlpEndpoint');
|
|
79
|
+
invariant(document.spec?.authority?.contract === 'bench' && document.spec.authority.deploymentTransaction === 'idp-deploy' && document.spec.authority.desiredState === 'gitops', 'IDP_GITOPS_BINDING_INVALID', 'EnvironmentBinding authority无效');
|
|
80
|
+
return document;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function repositoryHead(root) {
|
|
84
|
+
invariant(path.isAbsolute(root ?? ''), 'IDP_GITOPS_PATH_NOT_ABSOLUTE', '--gitops-root必须使用绝对路径');
|
|
85
|
+
const resolved = path.resolve(root);
|
|
86
|
+
invariant(fs.statSync(resolved, { throwIfNoEntry: false })?.isDirectory(), 'IDP_GITOPS_REPOSITORY_INVALID', 'GitOps仓库不存在');
|
|
87
|
+
const result = spawnSync('git', ['-C', resolved, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
|
|
88
|
+
invariant(result.status === 0 && /^[0-9a-f]{40}\n?$/u.test(result.stdout), 'IDP_GITOPS_REPOSITORY_INVALID', 'GitOps目录必须是已有初始提交的Git仓库');
|
|
89
|
+
return { root: resolved, head: result.stdout.trim() };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function treeState(target) {
|
|
93
|
+
const stat = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
94
|
+
if (!stat) return { exists: false, digest: sha256('absent'), files: [] };
|
|
95
|
+
invariant(stat.isDirectory() && !stat.isSymbolicLink(), 'IDP_GITOPS_TARGET_UNSAFE', 'GitOps应用目标必须是普通目录');
|
|
96
|
+
const files = inventoryDirectory(target);
|
|
97
|
+
invariant(!files.some((entry) => entry.unsupported), 'IDP_GITOPS_TARGET_UNSAFE', 'GitOps应用目标包含链接或特殊文件');
|
|
98
|
+
return { exists: true, digest: sha256(stableJson(files)), files };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const jsonYaml = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
102
|
+
|
|
103
|
+
function render(component, binding, candidate) {
|
|
104
|
+
const application = component.metadata.name;
|
|
105
|
+
const environment = binding.metadata.environment;
|
|
106
|
+
const caps = binding.spec.capabilities;
|
|
107
|
+
const imageMatch = candidate.image.ref.match(PINNED_IMAGE);
|
|
108
|
+
invariant(imageMatch && imageMatch[2] === candidate.image.digest, 'IDP_GITOPS_IMAGE_INVALID', '镜像引用与OCI digest不一致');
|
|
109
|
+
invariant(candidate.application === application && binding.metadata.consumer === application, 'IDP_GITOPS_CROSS_CONTRACT_INVALID', 'Component、Binding与ReleaseCandidate应用不一致');
|
|
110
|
+
invariant(candidate.rolloutStrategy === caps.rollout.strategy, 'IDP_GITOPS_CROSS_CONTRACT_INVALID', 'ReleaseCandidate与EnvironmentBinding发布策略不一致');
|
|
111
|
+
invariant(candidate.targetEnvironmentRef === `environment:${environment}@v1`, 'IDP_GITOPS_CROSS_CONTRACT_INVALID', 'ReleaseCandidate目标环境与EnvironmentBinding不一致');
|
|
112
|
+
invariant(candidate.image.ref.startsWith(`${caps.imageRegistry.registry}/${caps.imageRegistry.repository}@`) || (caps.imageRegistry.registry === 'docker.io' && candidate.image.ref.startsWith(`docker.io/${caps.imageRegistry.repository}@`)), 'IDP_GITOPS_CROSS_CONTRACT_INVALID', 'ReleaseCandidate镜像不属于EnvironmentBinding批准的Registry/Repository');
|
|
113
|
+
invariant(caps.tls.dnsNames.includes(caps.dns.hostname), 'IDP_GITOPS_CROSS_CONTRACT_INVALID', 'TLS dnsNames必须包含DNS hostname');
|
|
114
|
+
const container = {
|
|
115
|
+
name: application,
|
|
116
|
+
image: `invalid.local/${application}@sha256:${'0'.repeat(64)}`,
|
|
117
|
+
...(component.spec.runtime.args ? { args: component.spec.runtime.args } : {}),
|
|
118
|
+
ports: [{ name: 'http', containerPort: component.spec.image.port, protocol: 'TCP' }],
|
|
119
|
+
readinessProbe: { httpGet: { path: component.spec.health.readiness, port: 'http' }, periodSeconds: 5, failureThreshold: 6 },
|
|
120
|
+
livenessProbe: { httpGet: { path: component.spec.health.liveness, port: 'http' }, periodSeconds: 10, failureThreshold: 3 },
|
|
121
|
+
securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, runAsNonRoot: true, runAsUser: component.spec.runtime.runAsUser, runAsGroup: component.spec.runtime.runAsGroup, capabilities: { drop: ['ALL'] } },
|
|
122
|
+
resources: { requests: { cpu: '20m', memory: '32Mi' }, limits: { cpu: '200m', memory: '128Mi' } },
|
|
123
|
+
env: [{ name: 'OTEL_EXPORTER_OTLP_ENDPOINT', value: caps.observability.otlpEndpoint }],
|
|
124
|
+
};
|
|
125
|
+
const strategy = caps.rollout.strategy === 'canary'
|
|
126
|
+
? { canary: { stableService: `${application}-stable`, canaryService: `${application}-canary`, steps: [{ setWeight: 10 }, { pause: { duration: '30s' } }, { setWeight: 50 }, { pause: { duration: '60s' } }] } }
|
|
127
|
+
: { blueGreen: { activeService: `${application}-stable`, previewService: `${application}-preview`, autoPromotionEnabled: caps.rollout.promotion === 'automatic' } };
|
|
128
|
+
const rollout = {
|
|
129
|
+
apiVersion: 'argoproj.io/v1alpha1', kind: 'Rollout', metadata: { name: application },
|
|
130
|
+
spec: { replicas: 2, strategy, selector: { matchLabels: { 'app.kubernetes.io/name': application } }, template: { metadata: { labels: { 'app.kubernetes.io/name': application } }, spec: { serviceAccountName: caps.namespace.serviceAccountRef, containers: [container] } } },
|
|
131
|
+
};
|
|
132
|
+
const service = (suffix) => ({ apiVersion: 'v1', kind: 'Service', metadata: { name: `${application}-${suffix}` }, spec: { selector: { 'app.kubernetes.io/name': application }, ports: [{ name: 'http', port: 80, targetPort: 'http' }] } });
|
|
133
|
+
const ingress = {
|
|
134
|
+
apiVersion: 'networking.k8s.io/v1', kind: 'Ingress',
|
|
135
|
+
metadata: { name: application, annotations: { 'cert-manager.io/cluster-issuer': caps.tls.issuerKind === 'ClusterIssuer' ? caps.tls.issuerRef : undefined, 'cert-manager.io/issuer': caps.tls.issuerKind === 'Issuer' ? caps.tls.issuerRef : undefined, 'external-dns.alpha.kubernetes.io/hostname': caps.dns.hostname } },
|
|
136
|
+
spec: { ingressClassName: caps.ingress.className, tls: [{ hosts: caps.tls.dnsNames, secretName: caps.tls.certificateRef }], rules: [{ host: caps.dns.hostname, http: { paths: [{ path: '/', pathType: 'Prefix', backend: { service: { name: `${application}-stable`, port: { name: 'http' } } } }] } }] },
|
|
137
|
+
};
|
|
138
|
+
for (const [key, value] of Object.entries(ingress.metadata.annotations)) if (value === undefined) delete ingress.metadata.annotations[key];
|
|
139
|
+
const base = `applications/${application}/base`;
|
|
140
|
+
const overlay = `applications/${application}/overlays/${environment}`;
|
|
141
|
+
const services = [service('stable'), service(caps.rollout.strategy === 'canary' ? 'canary' : 'preview')];
|
|
142
|
+
const patch = [{ op: 'replace', path: '/spec/template/spec/containers/0/image', value: candidate.image.ref }];
|
|
143
|
+
const registration = { apiVersion: 'delivery.idp.dyyto/v1alpha1', kind: 'ApplicationRegistration', metadata: { name: `${application}-${environment}` }, spec: { application, environment, project: 'idp-workloads', owner: component.metadata.owner ?? binding.metadata.consumer, source: { path: overlay }, destination: { server: caps.cluster.server, namespace: caps.namespace.name } } };
|
|
144
|
+
const planId = `dp-${sha256(stableJson({ application, environment, candidate: candidate.digest, binding: binding.metadata.id })).slice(7, 23)}`;
|
|
145
|
+
const state = { apiVersion: 'delivery.idp.dyyto/v1alpha1', kind: 'ReleaseState', metadata: { name: `${application}-${environment}` }, spec: { candidateId: candidate.digest, planId, approvedAt: candidate.createdAt, image: candidate.image.ref } };
|
|
146
|
+
const files = {
|
|
147
|
+
[`${base}/kustomization.yaml`]: jsonYaml({ apiVersion: 'kustomize.config.k8s.io/v1beta1', kind: 'Kustomization', resources: ['service-account.yaml', 'rollout.yaml', 'services.yaml'] }),
|
|
148
|
+
[`${base}/service-account.yaml`]: jsonYaml({ apiVersion: 'v1', kind: 'ServiceAccount', metadata: { name: caps.namespace.serviceAccountRef }, automountServiceAccountToken: false }),
|
|
149
|
+
[`${base}/rollout.yaml`]: jsonYaml(rollout),
|
|
150
|
+
[`${base}/services.yaml`]: services.map(jsonYaml).join('---\n'),
|
|
151
|
+
[`${overlay}/kustomization.yaml`]: jsonYaml({ apiVersion: 'kustomize.config.k8s.io/v1beta1', kind: 'Kustomization', namespace: caps.namespace.name, resources: ['../../base', 'ingress.yaml'], patches: [{ target: { group: 'argoproj.io', version: 'v1alpha1', kind: 'Rollout', name: application }, path: 'deployment-patch.yaml' }] }),
|
|
152
|
+
[`${overlay}/deployment-patch.yaml`]: jsonYaml(patch),
|
|
153
|
+
[`${overlay}/ingress.yaml`]: jsonYaml(ingress),
|
|
154
|
+
[`${overlay}/application.yaml`]: jsonYaml(registration),
|
|
155
|
+
[`${overlay}/release-state.yaml`]: jsonYaml(state),
|
|
156
|
+
};
|
|
157
|
+
return Object.entries(files).sort(([left], [right]) => left.localeCompare(right)).map(([file, content]) => ({ path: file, digest: sha256(content), content }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function preserveUnmanagedApplicationFiles(repositoryRoot, application, environment, before, renderedFiles) {
|
|
161
|
+
if (!before.exists) return renderedFiles;
|
|
162
|
+
const managed = [`base/`, `overlays/${environment}/`];
|
|
163
|
+
const existing = before.files
|
|
164
|
+
.filter((entry) => !managed.some((prefix) => entry.path.startsWith(prefix)))
|
|
165
|
+
.map((entry) => {
|
|
166
|
+
const content = fs.readFileSync(path.join(repositoryRoot, 'applications', application, entry.path), 'utf8');
|
|
167
|
+
return { path: `applications/${application}/${entry.path}`, digest: sha256(content), content };
|
|
168
|
+
});
|
|
169
|
+
const combined = [...renderedFiles, ...existing].sort((left, right) => left.path.localeCompare(right.path));
|
|
170
|
+
invariant(new Set(combined.map((entry) => entry.path)).size === combined.length, 'IDP_GITOPS_RENDER_CONFLICT', 'Renderer输出路径冲突');
|
|
171
|
+
return combined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function safeOutput(output, configRoot) {
|
|
175
|
+
invariant(path.isAbsolute(output ?? ''), 'IDP_GITOPS_PATH_NOT_ABSOLUTE', '--output必须使用绝对路径');
|
|
176
|
+
const plans = path.resolve(configRoot, 'plans');
|
|
177
|
+
fs.mkdirSync(plans, { recursive: true, mode: 0o700 });
|
|
178
|
+
const resolved = path.resolve(output);
|
|
179
|
+
const relative = path.relative(plans, resolved);
|
|
180
|
+
invariant(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'IDP_GITOPS_PLAN_OUTSIDE_CONFIG', 'Plan必须写入IDP_CONFIG_DIR/plans');
|
|
181
|
+
invariant(!fs.existsSync(resolved), 'IDP_GITOPS_PLAN_EXISTS', '不可覆盖已有Plan');
|
|
182
|
+
return resolved;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function createGitOpsPlan({ operation, componentFile, bindingFile, candidateFile, gitopsRoot, output, configRoot, now = new Date().toISOString() }) {
|
|
186
|
+
invariant(['create', 'update', 'remove'].includes(operation), 'IDP_GITOPS_OPERATION_INVALID', 'GitOps Plan操作无效');
|
|
187
|
+
const componentInput = readDocumentFile(componentFile, 'ComponentContract');
|
|
188
|
+
const bindingInput = readDocumentFile(bindingFile, 'EnvironmentBinding');
|
|
189
|
+
const candidateInput = readDocumentFile(candidateFile, 'ReleaseCandidate');
|
|
190
|
+
const component = validateComponent(componentInput.value);
|
|
191
|
+
const binding = validateEnvironmentBinding(bindingInput.value);
|
|
192
|
+
const candidate = validateReleaseCandidate(candidateInput.value);
|
|
193
|
+
const repository = repositoryHead(gitopsRoot);
|
|
194
|
+
const applicationRoot = path.join(repository.root, 'applications', component.metadata.name);
|
|
195
|
+
const before = treeState(applicationRoot);
|
|
196
|
+
invariant(operation === 'create' ? !before.exists : before.exists, 'IDP_GITOPS_OPERATION_CONFLICT', operation === 'create' ? 'create目标已存在' : `${operation}目标不存在`);
|
|
197
|
+
const renderedFiles = operation === 'remove' ? [] : preserveUnmanagedApplicationFiles(repository.root, component.metadata.name, binding.metadata.environment, before, render(component, binding, candidate));
|
|
198
|
+
const payload = {
|
|
199
|
+
schemaVersion: 'idp.deployment-plan/v1', operation, application: component.metadata.name, environment: binding.metadata.environment,
|
|
200
|
+
inputs: { component: { path: componentInput.path, digest: componentInput.digest }, environmentBinding: { path: bindingInput.path, digest: bindingInput.digest }, releaseCandidate: { path: candidateInput.path, digest: candidateInput.digest, contractDigest: candidate.digest } },
|
|
201
|
+
repository: { root: repository.root, expectedHead: repository.head, applicationPath: `applications/${component.metadata.name}`, preimageDigest: before.digest },
|
|
202
|
+
renderedFiles, createdAt: now,
|
|
203
|
+
};
|
|
204
|
+
const plan = buildContract(payload);
|
|
205
|
+
const target = safeOutput(output, configRoot);
|
|
206
|
+
fs.writeFileSync(target, `${JSON.stringify(plan, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
207
|
+
return { schemaVersion: 'idp.gitops-plan-receipt/v1', status: 'planned', operation, plan: target, digest: plan.digest, files: renderedFiles.map((entry) => entry.path) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function validatePlan(plan) {
|
|
211
|
+
invariant(plan?.schemaVersion === 'idp.deployment-plan/v1' && ['create', 'update', 'remove'].includes(plan.operation) && NAME.test(plan.application ?? '') && ENVIRONMENT.test(plan.environment ?? ''), 'IDP_GITOPS_PLAN_INVALID', 'DeploymentPlan基础字段无效');
|
|
212
|
+
const { digest, ...payload } = plan;
|
|
213
|
+
invariant(DIGEST.test(digest ?? '') && digest === sha256(stableJson(payload)), 'IDP_GITOPS_PLAN_INVALID', 'DeploymentPlan摘要不匹配');
|
|
214
|
+
invariant(Array.isArray(plan.renderedFiles) && plan.renderedFiles.every((entry) => typeof entry.path === 'string' && !path.isAbsolute(entry.path) && !entry.path.split('/').includes('..') && entry.digest === sha256(entry.content)), 'IDP_GITOPS_PLAN_INVALID', 'DeploymentPlan渲染文件无效');
|
|
215
|
+
return plan;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function restoreTree(target, backup, existed) {
|
|
219
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
220
|
+
if (existed) fs.cpSync(backup, target, { recursive: true, errorOnExist: true, force: false });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function applyGitOpsPlan({ planFile, configRoot }) {
|
|
224
|
+
const planInput = readDocumentFile(planFile, 'DeploymentPlan');
|
|
225
|
+
const plan = validatePlan(planInput.value);
|
|
226
|
+
for (const input of Object.values(plan.inputs)) {
|
|
227
|
+
const current = readDocumentFile(input.path, 'Plan输入');
|
|
228
|
+
invariant(current.digest === input.digest, 'IDP_GITOPS_INPUT_CHANGED', `Plan输入已变化:${input.path}`);
|
|
229
|
+
}
|
|
230
|
+
const repository = repositoryHead(plan.repository.root);
|
|
231
|
+
invariant(repository.head === plan.repository.expectedHead, 'IDP_GITOPS_HEAD_CONFLICT', 'GitOps HEAD已变化,请重新生成Plan');
|
|
232
|
+
const target = path.join(repository.root, plan.repository.applicationPath);
|
|
233
|
+
const before = treeState(target);
|
|
234
|
+
invariant(before.digest === plan.repository.preimageDigest, 'IDP_GITOPS_PREIMAGE_CONFLICT', 'GitOps目标已被人工或并发修改,请重新生成Plan');
|
|
235
|
+
const evidenceRoot = path.join(configRoot, 'gitops-preimages', plan.digest.slice(7));
|
|
236
|
+
invariant(!fs.existsSync(evidenceRoot), 'IDP_GITOPS_PLAN_ALREADY_APPLIED', '该Plan已存在Preimage,拒绝重复Apply');
|
|
237
|
+
fs.mkdirSync(evidenceRoot, { recursive: true, mode: 0o700 });
|
|
238
|
+
if (before.exists) fs.cpSync(target, path.join(evidenceRoot, 'tree'), { recursive: true, errorOnExist: true, force: false });
|
|
239
|
+
fs.writeFileSync(path.join(evidenceRoot, 'manifest.json'), `${JSON.stringify({ planDigest: plan.digest, repositoryHead: repository.head, target: plan.repository.applicationPath, preimage: before }, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
240
|
+
const stage = path.join(repository.root, 'applications', `.idp-stage-${plan.digest.slice(7, 19)}`);
|
|
241
|
+
invariant(!fs.existsSync(stage), 'IDP_GITOPS_STAGE_EXISTS', 'GitOps暂存目录已存在');
|
|
242
|
+
try {
|
|
243
|
+
if (plan.operation !== 'remove') {
|
|
244
|
+
fs.mkdirSync(stage, { recursive: true, mode: 0o755 });
|
|
245
|
+
const prefix = `${plan.repository.applicationPath}/`;
|
|
246
|
+
for (const entry of plan.renderedFiles) {
|
|
247
|
+
invariant(entry.path.startsWith(prefix), 'IDP_GITOPS_PLAN_INVALID', '渲染路径越过应用边界');
|
|
248
|
+
const relative = entry.path.slice(prefix.length);
|
|
249
|
+
const destination = path.join(stage, relative);
|
|
250
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 });
|
|
251
|
+
fs.writeFileSync(destination, entry.content, { mode: 0o644, flag: 'wx' });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
255
|
+
if (plan.operation !== 'remove') fs.renameSync(stage, target);
|
|
256
|
+
const add = spawnSync('git', ['-C', repository.root, 'add', '-A', '--', plan.repository.applicationPath], { encoding: 'utf8' });
|
|
257
|
+
invariant(add.status === 0, 'IDP_GITOPS_GIT_FAILED', 'GitOps变更暂存失败');
|
|
258
|
+
const commit = spawnSync('git', ['-C', repository.root, '-c', 'user.name=idp-deploy', '-c', 'user.email=idp-deploy@local.invalid', 'commit', '--only', '-m', `idp(${plan.application}): ${plan.operation} ${plan.environment}`, '--', plan.repository.applicationPath], { encoding: 'utf8' });
|
|
259
|
+
invariant(commit.status === 0, 'IDP_GITOPS_GIT_FAILED', 'GitOps提交失败', { stderr: commit.stderr });
|
|
260
|
+
const applied = repositoryHead(repository.root);
|
|
261
|
+
const receipt = { schemaVersion: 'idp.gitops-apply-receipt/v1', status: 'applied', operation: plan.operation, planDigest: plan.digest, repository: repository.root, commit: applied.head, preimage: evidenceRoot };
|
|
262
|
+
fs.writeFileSync(path.join(evidenceRoot, 'receipt.json'), `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
263
|
+
return receipt;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
try { restoreTree(target, path.join(evidenceRoot, 'tree'), before.exists); } catch {}
|
|
266
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch {}
|
|
267
|
+
spawnSync('git', ['-C', repository.root, 'reset', '--quiet', '--', plan.repository.applicationPath], { encoding: 'utf8' });
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function verifyGitOpsRepository({ gitopsRoot, application, environment }) {
|
|
273
|
+
invariant(NAME.test(application ?? '') && ENVIRONMENT.test(environment ?? ''), 'IDP_GITOPS_VERIFY_ARGUMENT_INVALID', 'verify需要合法application与environment');
|
|
274
|
+
const repository = repositoryHead(gitopsRoot);
|
|
275
|
+
const overlay = path.join(repository.root, 'applications', application, 'overlays', environment);
|
|
276
|
+
const required = ['kustomization.yaml', 'deployment-patch.yaml', 'application.yaml', 'release-state.yaml'];
|
|
277
|
+
invariant(required.every((name) => fs.statSync(path.join(overlay, name), { throwIfNoEntry: false })?.isFile()), 'IDP_GITOPS_VERIFY_FAILED', 'GitOps overlay不完整');
|
|
278
|
+
const state = JSON.parse(fs.readFileSync(path.join(overlay, 'release-state.yaml'), 'utf8'));
|
|
279
|
+
const image = state.spec?.image;
|
|
280
|
+
const imageDigest = typeof image === 'string' && image.includes('@') ? image.slice(image.lastIndexOf('@') + 1) : '';
|
|
281
|
+
invariant(DIGEST.test(imageDigest), 'IDP_GITOPS_VERIFY_FAILED', 'release-state未使用OCI digest');
|
|
282
|
+
const rendered = spawnSync('kubectl', ['kustomize', overlay], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
|
|
283
|
+
invariant(rendered.status === 0 && rendered.stdout.includes(`@${imageDigest}`), 'IDP_GITOPS_VERIFY_FAILED', 'Kustomize渲染失败或实际镜像摘要不匹配');
|
|
284
|
+
return { schemaVersion: 'idp.gitops-repository-verification/v1', status: 'valid', application, environment, commit: repository.head, imageDigest, renderedDigest: sha256(rendered.stdout) };
|
|
285
|
+
}
|
package/src/hash.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function sha256(value) {
|
|
6
|
+
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function stableJson(value) {
|
|
10
|
+
if (Array.isArray(value)) return `[${value.filter((entry) => entry !== undefined).map(stableJson).join(',')}]`;
|
|
11
|
+
if (value && typeof value === 'object') {
|
|
12
|
+
return `{${Object.keys(value).filter((key) => value[key] !== undefined).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
13
|
+
}
|
|
14
|
+
return JSON.stringify(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function hashFile(candidate) {
|
|
18
|
+
const hash = crypto.createHash('sha256');
|
|
19
|
+
const handle = fs.openSync(candidate, 'r');
|
|
20
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
21
|
+
try {
|
|
22
|
+
for (;;) {
|
|
23
|
+
const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, null);
|
|
24
|
+
if (bytesRead === 0) break;
|
|
25
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
26
|
+
}
|
|
27
|
+
} finally {
|
|
28
|
+
fs.closeSync(handle);
|
|
29
|
+
}
|
|
30
|
+
return `sha256:${hash.digest('hex')}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function inventoryDirectory(root) {
|
|
34
|
+
const result = [];
|
|
35
|
+
function visit(directory) {
|
|
36
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
|
|
37
|
+
const absolute = path.join(directory, entry.name);
|
|
38
|
+
const relative = path.relative(root, absolute);
|
|
39
|
+
const stat = fs.lstatSync(absolute);
|
|
40
|
+
if (entry.isDirectory()) visit(absolute);
|
|
41
|
+
else if (entry.isFile()) result.push({ path: relative, mode: stat.mode & 0o777, size: stat.size, digest: hashFile(absolute) });
|
|
42
|
+
else result.push({ path: relative, unsupported: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
visit(root);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { IdpError, invariant } from './errors.mjs';
|
|
4
|
+
import { sha256, stableJson } from './hash.mjs';
|
|
5
|
+
import { assertSafeRegularFile } from './security.mjs';
|
|
6
|
+
|
|
7
|
+
export const IMAGE_KEYS = Object.freeze([
|
|
8
|
+
'IDP_POSTGRES_IMAGE',
|
|
9
|
+
'IDP_CADDY_IMAGE',
|
|
10
|
+
'IDP_REGISTRY_IMAGE',
|
|
11
|
+
'IDP_TECH_IMAGE',
|
|
12
|
+
'IDP_PORTAL_IMAGE',
|
|
13
|
+
'IDP_FLOW_IMAGE',
|
|
14
|
+
'IDP_SMARTGO_IMAGE',
|
|
15
|
+
'IDP_SMARTGO_OBJECT_STORE_IMAGE',
|
|
16
|
+
'IDP_SMARTGO_OBJECT_STORE_CLIENT_IMAGE',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export const REQUIRED_IMAGE_PLATFORMS = Object.freeze(['linux/amd64', 'linux/arm64']);
|
|
20
|
+
|
|
21
|
+
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
22
|
+
const PINNED_REF_PATTERN = /^\S+@sha256:[0-9a-f]{64}$/u;
|
|
23
|
+
const DOCUMENT_KEYS_V1 = ['schemaVersion', 'platform', 'generatedAt', 'images', 'digest'];
|
|
24
|
+
const ENTRY_KEYS_V1 = ['key', 'sourceRef', 'pinnedRef', 'digest', 'platform', 'evidence'];
|
|
25
|
+
const DOCUMENT_KEYS_V2 = ['schemaVersion', 'requiredPlatforms', 'generatedAt', 'images', 'digest'];
|
|
26
|
+
const ENTRY_KEYS_V2 = ['key', 'sourceRef', 'pinnedRef', 'digest', 'platforms'];
|
|
27
|
+
const PLATFORM_EVIDENCE_KEYS = ['platform', 'evidence'];
|
|
28
|
+
|
|
29
|
+
function exactKeys(value, expected, code, role) {
|
|
30
|
+
invariant(value && typeof value === 'object' && !Array.isArray(value), code, `${role}必须是对象`);
|
|
31
|
+
const actual = Object.keys(value).sort();
|
|
32
|
+
invariant(stableJson(actual) === stableJson([...expected].sort()), code, `${role}字段集合无效`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function lockPayload(document) {
|
|
36
|
+
const { digest: _digest, ...payload } = document;
|
|
37
|
+
return payload;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validatePlatformList(platforms, code, role) {
|
|
41
|
+
invariant(Array.isArray(platforms) && platforms.length > 0, code, `${role}不能为空`);
|
|
42
|
+
const values = platforms.map((entry) => typeof entry === 'string' ? entry : entry?.platform);
|
|
43
|
+
invariant(values.every((value) => REQUIRED_IMAGE_PLATFORMS.includes(value)), code, `${role}包含不支持的平台`);
|
|
44
|
+
invariant(stableJson(values) === stableJson([...new Set(values)].sort()), code, `${role}必须唯一并按字典序排列`);
|
|
45
|
+
return values;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateCommonEntry(entry, entries) {
|
|
49
|
+
invariant(IMAGE_KEYS.includes(entry.key), 'IDP_IMAGE_LOCK_KEY_UNKNOWN', `镜像锁包含未知镜像键:${entry.key}`);
|
|
50
|
+
invariant(!entries.has(entry.key), 'IDP_IMAGE_LOCK_KEY_DUPLICATE', `镜像锁包含重复镜像键:${entry.key}`);
|
|
51
|
+
invariant(typeof entry.sourceRef === 'string' && entry.sourceRef.length > 0 && !/\s/u.test(entry.sourceRef), 'IDP_IMAGE_LOCK_SOURCE_INVALID', `${entry.key}的sourceRef无效`);
|
|
52
|
+
invariant(PINNED_REF_PATTERN.test(entry.pinnedRef), 'IDP_IMAGE_LOCK_PIN_INVALID', `${entry.key}的pinnedRef无效`);
|
|
53
|
+
invariant(DIGEST_PATTERN.test(entry.digest), 'IDP_IMAGE_LOCK_DIGEST_INVALID', `${entry.key}的digest无效`);
|
|
54
|
+
invariant(entry.pinnedRef.endsWith(`@${entry.digest}`), 'IDP_IMAGE_LOCK_DIGEST_MISMATCH', `${entry.key}的pinnedRef与digest不一致`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateEnvironmentBindings(entries, env, requiredKeys) {
|
|
58
|
+
for (const key of requiredKeys) invariant(entries.has(key), 'IDP_IMAGE_LOCK_REQUIRED_MISSING', `镜像锁缺少必需镜像:${key}`);
|
|
59
|
+
if (!env) return;
|
|
60
|
+
// Profile-scoped callers only validate images used by that profile. Future
|
|
61
|
+
// profile tags may already be present in .env without becoming deployment
|
|
62
|
+
// facts for the currently active profile.
|
|
63
|
+
const scopedKeys = requiredKeys.length > 0 ? new Set(requiredKeys) : new Set(IMAGE_KEYS);
|
|
64
|
+
for (const key of scopedKeys) {
|
|
65
|
+
const configured = env[key];
|
|
66
|
+
if (!configured || configured === 'UNCONFIGURED') continue;
|
|
67
|
+
const entry = entries.get(key);
|
|
68
|
+
invariant(entry, 'IDP_IMAGE_LOCK_CONFIG_MISSING', `已配置镜像没有锁定证据:${key}`);
|
|
69
|
+
invariant(entry.pinnedRef === configured, 'IDP_IMAGE_LOCK_CONFIG_MISMATCH', `${key}与镜像锁不一致`);
|
|
70
|
+
}
|
|
71
|
+
for (const [key, entry] of entries) {
|
|
72
|
+
if (!scopedKeys.has(key)) continue;
|
|
73
|
+
invariant(env[key] && env[key] !== 'UNCONFIGURED', 'IDP_IMAGE_LOCK_CONFIG_EXTRA', `镜像锁包含.env未配置的镜像:${key}`);
|
|
74
|
+
invariant(env[key] === entry.pinnedRef, 'IDP_IMAGE_LOCK_CONFIG_MISMATCH', `${key}与镜像锁不一致`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateLegacyImageLock(document, { env, requiredKeys, allowLegacy }) {
|
|
79
|
+
invariant(allowLegacy, 'IDP_IMAGE_LOCK_VERSION_UNSUPPORTED', '旧版单平台镜像锁证据不足;请使用新的不可变版本执行./deploy.sh重新发布并锁定双平台镜像');
|
|
80
|
+
exactKeys(document, DOCUMENT_KEYS_V1, 'IDP_IMAGE_LOCK_INVALID', '镜像锁');
|
|
81
|
+
invariant(document.platform === 'linux/arm64', 'IDP_IMAGE_LOCK_PLATFORM_INVALID', '旧版镜像锁平台必须是linux/arm64');
|
|
82
|
+
const generatedAt = typeof document.generatedAt === 'string' ? Date.parse(document.generatedAt) : Number.NaN;
|
|
83
|
+
invariant(Number.isFinite(generatedAt) && new Date(generatedAt).toISOString() === document.generatedAt, 'IDP_IMAGE_LOCK_TIME_INVALID', '旧版镜像锁generatedAt无效');
|
|
84
|
+
invariant(Array.isArray(document.images), 'IDP_IMAGE_LOCK_IMAGES_INVALID', '镜像锁images必须是数组');
|
|
85
|
+
const entries = new Map();
|
|
86
|
+
for (const entry of document.images) {
|
|
87
|
+
exactKeys(entry, ENTRY_KEYS_V1, 'IDP_IMAGE_LOCK_ENTRY_INVALID', '镜像锁条目');
|
|
88
|
+
validateCommonEntry(entry, entries);
|
|
89
|
+
invariant(entry.platform === document.platform, 'IDP_IMAGE_LOCK_ENTRY_PLATFORM_MISMATCH', `${entry.key}的平台与镜像锁不一致`);
|
|
90
|
+
invariant(typeof entry.evidence === 'string' && entry.evidence.length > 0, 'IDP_IMAGE_LOCK_EVIDENCE_INVALID', `${entry.key}缺少平台证据`);
|
|
91
|
+
entries.set(entry.key, entry);
|
|
92
|
+
}
|
|
93
|
+
validateEnvironmentBindings(entries, env, requiredKeys);
|
|
94
|
+
return { document, entries, legacy: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function buildImageLock(images, { requiredPlatforms = REQUIRED_IMAGE_PLATFORMS, generatedAt = new Date().toISOString() } = {}) {
|
|
98
|
+
const canonicalPlatforms = [...requiredPlatforms];
|
|
99
|
+
validatePlatformList(canonicalPlatforms, 'IDP_IMAGE_LOCK_REQUIRED_PLATFORMS_INVALID', '镜像锁requiredPlatforms');
|
|
100
|
+
const payload = {
|
|
101
|
+
schemaVersion: 2,
|
|
102
|
+
requiredPlatforms: canonicalPlatforms,
|
|
103
|
+
generatedAt,
|
|
104
|
+
images: [...images].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0),
|
|
105
|
+
};
|
|
106
|
+
return { ...payload, digest: sha256(stableJson(payload)) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function validateImageLockDocument(document, {
|
|
110
|
+
env,
|
|
111
|
+
requiredPlatforms = REQUIRED_IMAGE_PLATFORMS,
|
|
112
|
+
requiredKeys = [],
|
|
113
|
+
allowLegacy = false,
|
|
114
|
+
} = {}) {
|
|
115
|
+
invariant(document && typeof document === 'object' && !Array.isArray(document), 'IDP_IMAGE_LOCK_INVALID', '镜像锁必须是对象');
|
|
116
|
+
const expectedDigest = sha256(stableJson(lockPayload(document)));
|
|
117
|
+
invariant(document.digest === expectedDigest, 'IDP_IMAGE_LOCK_DOCUMENT_DIGEST_MISMATCH', '镜像锁整体摘要不匹配');
|
|
118
|
+
if (document.schemaVersion === 1) return validateLegacyImageLock(document, { env, requiredKeys, allowLegacy });
|
|
119
|
+
invariant(document.schemaVersion === 2, 'IDP_IMAGE_LOCK_VERSION_UNSUPPORTED', '只支持schemaVersion=2的双平台镜像锁');
|
|
120
|
+
exactKeys(document, DOCUMENT_KEYS_V2, 'IDP_IMAGE_LOCK_INVALID', '镜像锁');
|
|
121
|
+
const declaredPlatforms = validatePlatformList(document.requiredPlatforms, 'IDP_IMAGE_LOCK_REQUIRED_PLATFORMS_INVALID', '镜像锁requiredPlatforms');
|
|
122
|
+
const expectedPlatforms = validatePlatformList([...requiredPlatforms], 'IDP_IMAGE_LOCK_REQUIRED_PLATFORMS_INVALID', '要求的平台集合');
|
|
123
|
+
for (const platform of expectedPlatforms) {
|
|
124
|
+
invariant(declaredPlatforms.includes(platform), 'IDP_IMAGE_LOCK_PLATFORM_MISSING', `镜像锁没有证明必需平台:${platform}`);
|
|
125
|
+
}
|
|
126
|
+
const generatedAt = typeof document.generatedAt === 'string' ? Date.parse(document.generatedAt) : Number.NaN;
|
|
127
|
+
invariant(Number.isFinite(generatedAt) && new Date(generatedAt).toISOString() === document.generatedAt, 'IDP_IMAGE_LOCK_TIME_INVALID', '镜像锁generatedAt无效');
|
|
128
|
+
invariant(Array.isArray(document.images), 'IDP_IMAGE_LOCK_IMAGES_INVALID', '镜像锁images必须是数组');
|
|
129
|
+
|
|
130
|
+
const entries = new Map();
|
|
131
|
+
for (const entry of document.images) {
|
|
132
|
+
exactKeys(entry, ENTRY_KEYS_V2, 'IDP_IMAGE_LOCK_ENTRY_INVALID', '镜像锁条目');
|
|
133
|
+
validateCommonEntry(entry, entries);
|
|
134
|
+
const entryPlatforms = validatePlatformList(entry.platforms, 'IDP_IMAGE_LOCK_ENTRY_PLATFORMS_INVALID', `${entry.key}的平台证据`);
|
|
135
|
+
for (const platformEvidence of entry.platforms) {
|
|
136
|
+
exactKeys(platformEvidence, PLATFORM_EVIDENCE_KEYS, 'IDP_IMAGE_LOCK_ENTRY_PLATFORMS_INVALID', `${entry.key}的平台证据项`);
|
|
137
|
+
invariant(typeof platformEvidence.evidence === 'string' && platformEvidence.evidence.length > 0, 'IDP_IMAGE_LOCK_EVIDENCE_INVALID', `${entry.key}的${platformEvidence.platform}缺少平台证据`);
|
|
138
|
+
}
|
|
139
|
+
for (const platform of declaredPlatforms) {
|
|
140
|
+
invariant(entryPlatforms.includes(platform), 'IDP_IMAGE_LOCK_ENTRY_PLATFORM_MISSING', `${entry.key}没有证明必需平台:${platform}`);
|
|
141
|
+
}
|
|
142
|
+
entries.set(entry.key, entry);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
validateEnvironmentBindings(entries, env, requiredKeys);
|
|
146
|
+
return { document, entries, legacy: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function readImageLock(configRoot, options = {}) {
|
|
150
|
+
invariant(path.isAbsolute(configRoot), 'IDP_CONFIG_NOT_ABSOLUTE', 'IDP_CONFIG_DIR必须是绝对路径');
|
|
151
|
+
const candidate = path.join(configRoot, 'images.lock.json');
|
|
152
|
+
assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: '镜像锁' });
|
|
153
|
+
let document;
|
|
154
|
+
try {
|
|
155
|
+
document = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw new IdpError('IDP_IMAGE_LOCK_JSON_INVALID', '镜像锁不是有效JSON', { cause: error.message });
|
|
158
|
+
}
|
|
159
|
+
return { path: candidate, ...validateImageLockDocument(document, options) };
|
|
160
|
+
}
|