|
|
|
@@ -49,6 +49,7 @@ interface TestTargetSpec {
|
|
|
|
|
commitPolicy: string;
|
|
|
|
|
apiImage: string;
|
|
|
|
|
workerImage: string;
|
|
|
|
|
migrationImage: string;
|
|
|
|
|
pullPolicy: string;
|
|
|
|
|
};
|
|
|
|
|
database: {
|
|
|
|
@@ -62,6 +63,14 @@ interface TestTargetSpec {
|
|
|
|
|
secretName: string;
|
|
|
|
|
apiPort: number;
|
|
|
|
|
workerMetricsPort: number;
|
|
|
|
|
attachment: {
|
|
|
|
|
storageRoot: string;
|
|
|
|
|
maxBytes: number;
|
|
|
|
|
claimName: string;
|
|
|
|
|
storageRequest: string;
|
|
|
|
|
storageClassName: string | null;
|
|
|
|
|
accessModes: string[];
|
|
|
|
|
};
|
|
|
|
|
workerInterval: string;
|
|
|
|
|
outboxBatchSize: number;
|
|
|
|
|
outboxMaxAttempts: number;
|
|
|
|
@@ -72,6 +81,11 @@ interface TestTargetSpec {
|
|
|
|
|
employee: { username: string; password: SecretSourceSpec };
|
|
|
|
|
sessionSecret: SecretSourceSpec;
|
|
|
|
|
};
|
|
|
|
|
migration: {
|
|
|
|
|
jobName: string;
|
|
|
|
|
backoffLimit: number;
|
|
|
|
|
command: string[];
|
|
|
|
|
};
|
|
|
|
|
observability: {
|
|
|
|
|
otlpEndpoint: string;
|
|
|
|
|
prometheusScrape: boolean;
|
|
|
|
@@ -117,6 +131,7 @@ interface RenderContext {
|
|
|
|
|
expiresAt: string;
|
|
|
|
|
apiImage: string;
|
|
|
|
|
workerImage: string;
|
|
|
|
|
migrationImage: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SecretMaterial {
|
|
|
|
@@ -242,6 +257,7 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
|
|
|
|
|
const images = record(source.images, `${path}.source.images`);
|
|
|
|
|
const database = record(root.database, `${path}.database`);
|
|
|
|
|
const runtime = record(root.runtime, `${path}.runtime`);
|
|
|
|
|
const attachment = record(runtime.attachment, `${path}.runtime.attachment`);
|
|
|
|
|
const outbox = record(runtime.outbox, `${path}.runtime.outbox`);
|
|
|
|
|
const administrator = record(runtime.administrator, `${path}.runtime.administrator`);
|
|
|
|
|
const employee = record(runtime.employee, `${path}.runtime.employee`);
|
|
|
|
@@ -252,6 +268,7 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
|
|
|
|
|
const apiProbes = record(probes.api, `${path}.probes.api`);
|
|
|
|
|
const workerProbes = record(probes.worker, `${path}.probes.worker`);
|
|
|
|
|
const cleanup = record(root.cleanup, `${path}.cleanup`);
|
|
|
|
|
const migration = record(root.migration, `${path}.migration`);
|
|
|
|
|
if (exporterFailure.blocking !== false) throw new Error(`${path}.observability.exporterFailure.blocking 必须为 false`);
|
|
|
|
|
if (exporterFailure.warning !== true) throw new Error(`${path}.observability.exporterFailure.warning 必须为 true`);
|
|
|
|
|
if (cleanup.requireOwnershipLabels !== true) throw new Error(`${path}.cleanup.requireOwnershipLabels 必须为 true`);
|
|
|
|
@@ -276,6 +293,7 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
|
|
|
|
|
commitPolicy: exactString(source.commitPolicy, `${path}.source.commitPolicy`, "cli-required"),
|
|
|
|
|
apiImage: imageReference(images.api, `${path}.source.images.api`),
|
|
|
|
|
workerImage: imageReference(images.worker, `${path}.source.images.worker`),
|
|
|
|
|
migrationImage: imageReference(images.migration, `${path}.source.images.migration`),
|
|
|
|
|
pullPolicy: enumString(source.pullPolicy, `${path}.source.pullPolicy`, ["Always", "IfNotPresent", "Never"]),
|
|
|
|
|
},
|
|
|
|
|
database: {
|
|
|
|
@@ -289,6 +307,14 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
|
|
|
|
|
secretName: kubernetesName(nonEmpty(runtime.secretName, `${path}.runtime.secretName`), `${path}.runtime.secretName`, 63),
|
|
|
|
|
apiPort: positiveInteger(runtime.apiPort, `${path}.runtime.apiPort`, 65_535),
|
|
|
|
|
workerMetricsPort: positiveInteger(runtime.workerMetricsPort, `${path}.runtime.workerMetricsPort`, 65_535),
|
|
|
|
|
attachment: {
|
|
|
|
|
storageRoot: absolutePath(attachment.storageRoot, `${path}.runtime.attachment.storageRoot`),
|
|
|
|
|
maxBytes: positiveInteger(attachment.maxBytes, `${path}.runtime.attachment.maxBytes`, Number.MAX_SAFE_INTEGER),
|
|
|
|
|
claimName: kubernetesName(nonEmpty(attachment.claimName, `${path}.runtime.attachment.claimName`), `${path}.runtime.attachment.claimName`, 63),
|
|
|
|
|
storageRequest: nonEmpty(attachment.storageRequest, `${path}.runtime.attachment.storageRequest`),
|
|
|
|
|
storageClassName: nullableString(attachment.storageClassName, `${path}.runtime.attachment.storageClassName`),
|
|
|
|
|
accessModes: enumStringList(attachment.accessModes, `${path}.runtime.attachment.accessModes`, ["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]),
|
|
|
|
|
},
|
|
|
|
|
workerInterval: duration(runtime.workerInterval, `${path}.runtime.workerInterval`),
|
|
|
|
|
outboxBatchSize: positiveInteger(outbox.batchSize, `${path}.runtime.outbox.batchSize`, 1000),
|
|
|
|
|
outboxMaxAttempts: positiveInteger(outbox.maxAttempts, `${path}.runtime.outbox.maxAttempts`, 100),
|
|
|
|
@@ -305,6 +331,11 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
|
|
|
|
|
},
|
|
|
|
|
sessionSecret: secretSource(runtime.sessionSecret, "runtime.sessionSecret", `${path}.runtime.sessionSecret`),
|
|
|
|
|
},
|
|
|
|
|
migration: {
|
|
|
|
|
jobName: kubernetesName(nonEmpty(migration.jobName, `${path}.migration.jobName`), `${path}.migration.jobName`, 63),
|
|
|
|
|
backoffLimit: nonNegativeInteger(migration.backoffLimit, `${path}.migration.backoffLimit`),
|
|
|
|
|
command: nonEmptyStringList(migration.command, `${path}.migration.command`),
|
|
|
|
|
},
|
|
|
|
|
observability: {
|
|
|
|
|
otlpEndpoint: nonEmpty(observability.otlpEndpoint, `${path}.observability.otlpEndpoint`),
|
|
|
|
|
prometheusScrape: boolean(prometheus.scrape, `${path}.observability.prometheus.scrape`),
|
|
|
|
@@ -406,6 +437,20 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
|
|
|
|
|
prometheusScrape: target.observability.prometheusScrape,
|
|
|
|
|
exporterFailure: { warning: target.observability.exporterWarning, blocking: false },
|
|
|
|
|
},
|
|
|
|
|
attachment: {
|
|
|
|
|
storageRoot: target.runtime.attachment.storageRoot,
|
|
|
|
|
maxBytes: target.runtime.attachment.maxBytes,
|
|
|
|
|
claimName: target.runtime.attachment.claimName,
|
|
|
|
|
storageRequest: target.runtime.attachment.storageRequest,
|
|
|
|
|
storageClassName: target.runtime.attachment.storageClassName,
|
|
|
|
|
accessModes: target.runtime.attachment.accessModes,
|
|
|
|
|
},
|
|
|
|
|
migration: {
|
|
|
|
|
jobName: target.migration.jobName,
|
|
|
|
|
image: context.migrationImage,
|
|
|
|
|
command: target.migration.command,
|
|
|
|
|
applyPhase: "after-postgres-before-workloads",
|
|
|
|
|
},
|
|
|
|
|
manifestFingerprint: structuralFingerprint(structuralManifest),
|
|
|
|
|
mutation: false,
|
|
|
|
|
};
|
|
|
|
@@ -489,6 +534,7 @@ function renderContext(options: TestTargetOptions, target: TestTargetSpec, requi
|
|
|
|
|
expiresAt,
|
|
|
|
|
apiImage: renderImage(target.source.apiImage, commit),
|
|
|
|
|
workerImage: renderImage(target.source.workerImage, commit),
|
|
|
|
|
migrationImage: renderImage(target.source.migrationImage, commit),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -512,8 +558,9 @@ function safetyBlockers(options: TestTargetOptions, selection: Selection, contex
|
|
|
|
|
|
|
|
|
|
function consistencyWarnings(target: TestTargetSpec, context: RenderContext | null): Warning[] {
|
|
|
|
|
const warnings: Warning[] = [];
|
|
|
|
|
if (!target.source.apiImage.includes("${commit}") || !target.source.workerImage.includes("${commit}")) warnings.push(warning("image-template-not-commit-scoped", "API 或 Worker 镜像模板未包含 ${commit};版本一致性只报告 warning。"));
|
|
|
|
|
if (context !== null && (!context.apiImage.includes(context.commit) || !context.workerImage.includes(context.commit))) warnings.push(warning("image-commit-not-visible", "渲染镜像引用未显示选中 commit;该漂移不阻塞当前 MVP。"));
|
|
|
|
|
if (!target.migration.command.includes("migrate") || !target.migration.command.includes("up")) warnings.push(warning("migration-command-unrecognized", "迁移命令未显示产品 migrate up;配置一致性只报告 warning。"));
|
|
|
|
|
if (!target.source.apiImage.includes("${commit}") || !target.source.workerImage.includes("${commit}") || !target.source.migrationImage.includes("${commit}")) warnings.push(warning("image-template-not-commit-scoped", "API、Worker 或迁移镜像模板未包含 ${commit};版本一致性只报告 warning。"));
|
|
|
|
|
if (context !== null && (!context.apiImage.includes(context.commit) || !context.workerImage.includes(context.commit) || !context.migrationImage.includes(context.commit))) warnings.push(warning("image-commit-not-visible", "渲染镜像引用未显示选中 commit;该漂移不阻塞当前 MVP。"));
|
|
|
|
|
if (target.observability.exporterWarning) warnings.push(warning("otel-exporter-warning-only", "OTel exporter 失败按 owning YAML 仅报告 warning,不阻塞业务运行。"));
|
|
|
|
|
if (!target.observability.prometheusScrape) warnings.push(warning("prometheus-scrape-disabled", "Prometheus scrape 已在 target YAML 中关闭。"));
|
|
|
|
|
return warnings;
|
|
|
|
@@ -525,6 +572,7 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets:
|
|
|
|
|
const apiSelector = componentSelector("api", context.instanceId);
|
|
|
|
|
const workerSelector = componentSelector("worker", context.instanceId);
|
|
|
|
|
const postgresSelector = componentSelector("postgres", context.instanceId);
|
|
|
|
|
const migrationSelector = componentSelector("migration", context.instanceId);
|
|
|
|
|
const commonPodAnnotations = (path: string, port: number): Record<string, string> => target.observability.prometheusScrape ? {
|
|
|
|
|
"prometheus.io/scrape": "true",
|
|
|
|
|
"prometheus.io/path": path,
|
|
|
|
@@ -537,6 +585,14 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets:
|
|
|
|
|
{
|
|
|
|
|
apiVersion: "v1", kind: "Secret", metadata: { name: target.runtime.secretName, namespace: context.namespace, labels }, type: "Opaque", stringData: secretData,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: target.runtime.attachment.claimName, namespace: context.namespace, labels },
|
|
|
|
|
spec: {
|
|
|
|
|
accessModes: target.runtime.attachment.accessModes,
|
|
|
|
|
resources: { requests: { storage: target.runtime.attachment.storageRequest } },
|
|
|
|
|
...(target.runtime.attachment.storageClassName === null ? {} : { storageClassName: target.runtime.attachment.storageClassName }),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
apiVersion: "v1", kind: "Service", metadata: { name: "pikaoa-postgres", namespace: context.namespace, labels: { ...labels, ...postgresSelector } },
|
|
|
|
|
spec: { selector: postgresSelector, ports: [{ name: "postgres", port: target.database.port, targetPort: target.database.port }] },
|
|
|
|
@@ -569,6 +625,25 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets:
|
|
|
|
|
apiVersion: "v1", kind: "Service", metadata: { name: "pikaoa-api", namespace: context.namespace, labels: { ...labels, ...apiSelector } },
|
|
|
|
|
spec: { selector: apiSelector, ports: [{ name: "http", port: target.runtime.apiPort, targetPort: target.runtime.apiPort }] },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
apiVersion: "batch/v1", kind: "Job", metadata: { name: target.migration.jobName, namespace: context.namespace, labels: { ...labels, ...migrationSelector } },
|
|
|
|
|
spec: {
|
|
|
|
|
backoffLimit: target.migration.backoffLimit,
|
|
|
|
|
template: {
|
|
|
|
|
metadata: { labels: { ...labels, ...migrationSelector } },
|
|
|
|
|
spec: {
|
|
|
|
|
restartPolicy: "Never",
|
|
|
|
|
containers: [{
|
|
|
|
|
name: "migration", image: context.migrationImage, imagePullPolicy: target.source.pullPolicy,
|
|
|
|
|
command: target.migration.command,
|
|
|
|
|
env: [{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }],
|
|
|
|
|
volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }],
|
|
|
|
|
}],
|
|
|
|
|
volumes: [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
deployment(target, context, "api", context.apiImage, apiSelector, target.runtime.apiPort, commonPodAnnotations(target.observability.apiMetricsPath, target.runtime.apiPort), [
|
|
|
|
|
{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" },
|
|
|
|
|
{ name: "PIKAOA_SESSION_SECRET", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.sessionSecret.targetKey } } },
|
|
|
|
@@ -606,11 +681,17 @@ function deployment(
|
|
|
|
|
containers: [{
|
|
|
|
|
name: component, image, imagePullPolicy: target.source.pullPolicy, env,
|
|
|
|
|
ports: [{ name: component === "api" ? "http" : "metrics", containerPort: port }],
|
|
|
|
|
volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }],
|
|
|
|
|
volumeMounts: [
|
|
|
|
|
{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true },
|
|
|
|
|
...(component === "api" ? [{ name: target.runtime.attachment.claimName, mountPath: target.runtime.attachment.storageRoot }] : []),
|
|
|
|
|
],
|
|
|
|
|
livenessProbe: { httpGet: { path: livenessPath, port }, periodSeconds: 5, failureThreshold: 12 },
|
|
|
|
|
readinessProbe: { httpGet: { path: readinessPath, port }, periodSeconds: 3, failureThreshold: 20 },
|
|
|
|
|
}],
|
|
|
|
|
volumes: [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }],
|
|
|
|
|
volumes: [
|
|
|
|
|
{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } },
|
|
|
|
|
...(component === "api" ? [{ name: target.runtime.attachment.claimName, persistentVolumeClaim: { claimName: target.runtime.attachment.claimName } }] : []),
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
@@ -638,6 +719,9 @@ function runtimeSecretData(target: TestTargetSpec, context: RenderContext, secre
|
|
|
|
|
` url: ${JSON.stringify(databaseURL)}`,
|
|
|
|
|
"identity:",
|
|
|
|
|
` sessionTTL: ${target.runtime.sessionTTL}`,
|
|
|
|
|
"attachment:",
|
|
|
|
|
` storageRoot: ${JSON.stringify(target.runtime.attachment.storageRoot)}`,
|
|
|
|
|
` maxBytes: ${target.runtime.attachment.maxBytes}`,
|
|
|
|
|
"observability:",
|
|
|
|
|
` serviceName: pikaoa-test-${context.instanceId}`,
|
|
|
|
|
` otlpEndpoint: ${JSON.stringify(target.observability.otlpEndpoint)}`,
|
|
|
|
@@ -687,7 +771,13 @@ function readSecretMaterial(selection: Selection, target: TestTargetSpec): {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startScript(target: TestTargetSpec, context: RenderContext, manifest: Record<string, unknown>[]): string {
|
|
|
|
|
const encoded = Buffer.from(manifest.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64");
|
|
|
|
|
const migration = manifest.filter((object) => object.kind === "Job");
|
|
|
|
|
const workloads = manifest.filter((object) => object.kind === "Deployment");
|
|
|
|
|
const foundation = manifest.filter((object) => object.kind !== "Job" && object.kind !== "Deployment");
|
|
|
|
|
const encode = (objects: Record<string, unknown>[]): string => Buffer.from(objects.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64");
|
|
|
|
|
const foundationEncoded = encode(foundation);
|
|
|
|
|
const migrationEncoded = encode(migration);
|
|
|
|
|
const workloadsEncoded = encode(workloads);
|
|
|
|
|
return `
|
|
|
|
|
set -u
|
|
|
|
|
namespace=${shQuote(context.namespace)}
|
|
|
|
@@ -705,20 +795,42 @@ if kubectl get namespace "$namespace" >/dev/null 2>&1; then
|
|
|
|
|
exit 42
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
printf '%s' ${shQuote(encoded)} | base64 -d >"$tmp/manifest.yaml"
|
|
|
|
|
if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/manifest.yaml" >"$tmp/apply.out" 2>"$tmp/apply.err"; then
|
|
|
|
|
printf '%s' ${shQuote(foundationEncoded)} | base64 -d >"$tmp/foundation.yaml"
|
|
|
|
|
printf '%s' ${shQuote(migrationEncoded)} | base64 -d >"$tmp/migration.yaml"
|
|
|
|
|
printf '%s' ${shQuote(workloadsEncoded)} | base64 -d >"$tmp/workloads.yaml"
|
|
|
|
|
if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/foundation.yaml" >"$tmp/apply.out" 2>"$tmp/apply.err"; then
|
|
|
|
|
cat "$tmp/apply.err" >&2
|
|
|
|
|
printf '%s\n' '{"ok":false,"mutation":true,"code":"apply-failed","valuesPrinted":false}'
|
|
|
|
|
exit 43
|
|
|
|
|
fi
|
|
|
|
|
wait_rc=0
|
|
|
|
|
kubectl -n "$namespace" rollout status statefulset/pikaoa-postgres --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/wait.err" || wait_rc=$?
|
|
|
|
|
if [ "$wait_rc" -eq 0 ]; then kubectl -n "$namespace" rollout status deployment/pikaoa-api --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>>"$tmp/wait.err" || wait_rc=$?; fi
|
|
|
|
|
if [ "$wait_rc" -ne 0 ]; then
|
|
|
|
|
cat "$tmp/wait.err" >&2
|
|
|
|
|
printf '%s\n' '{"ok":false,"mutation":true,"code":"postgres-wait-failed","valuesPrinted":false}'
|
|
|
|
|
exit 44
|
|
|
|
|
fi
|
|
|
|
|
if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/migration.yaml" >"$tmp/migration-apply.out" 2>"$tmp/migration-apply.err"; then
|
|
|
|
|
cat "$tmp/migration-apply.err" >&2
|
|
|
|
|
printf '%s\n' '{"ok":false,"mutation":true,"code":"migration-apply-failed","valuesPrinted":false}'
|
|
|
|
|
exit 45
|
|
|
|
|
fi
|
|
|
|
|
if ! kubectl -n "$namespace" wait --for=condition=complete job/${target.migration.jobName} --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/migration-wait.err"; then
|
|
|
|
|
cat "$tmp/migration-wait.err" >&2
|
|
|
|
|
printf '%s\n' '{"ok":false,"mutation":true,"code":"migration-wait-failed","valuesPrinted":false}'
|
|
|
|
|
exit 46
|
|
|
|
|
fi
|
|
|
|
|
if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/workloads.yaml" >"$tmp/workloads-apply.out" 2>"$tmp/workloads-apply.err"; then
|
|
|
|
|
cat "$tmp/workloads-apply.err" >&2
|
|
|
|
|
printf '%s\n' '{"ok":false,"mutation":true,"code":"workloads-apply-failed","valuesPrinted":false}'
|
|
|
|
|
exit 47
|
|
|
|
|
fi
|
|
|
|
|
kubectl -n "$namespace" rollout status deployment/pikaoa-api --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/wait.err" || wait_rc=$?
|
|
|
|
|
if [ "$wait_rc" -eq 0 ]; then kubectl -n "$namespace" rollout status deployment/pikaoa-worker --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>>"$tmp/wait.err" || wait_rc=$?; fi
|
|
|
|
|
if [ "$wait_rc" -ne 0 ]; then
|
|
|
|
|
cat "$tmp/wait.err" >&2
|
|
|
|
|
printf '%s\n' '{"ok":false,"mutation":true,"code":"rollout-wait-failed","valuesPrinted":false}'
|
|
|
|
|
exit 44
|
|
|
|
|
exit 48
|
|
|
|
|
fi
|
|
|
|
|
printf '%s\n' '{"ok":true,"mutation":true,"code":"started","valuesPrinted":false}'
|
|
|
|
|
`;
|
|
|
|
@@ -735,7 +847,7 @@ fi
|
|
|
|
|
printf '%s' '{"ok":true,"exists":true,"mutation":false,"namespace":'
|
|
|
|
|
kubectl get namespace "$namespace" -o json
|
|
|
|
|
printf '%s' ',"resources":'
|
|
|
|
|
kubectl -n "$namespace" get deployment,statefulset,service,pod -o json
|
|
|
|
|
kubectl -n "$namespace" get deployment,statefulset,job,service,persistentvolumeclaim,pod -o json
|
|
|
|
|
printf '%s\n' ',"valuesPrinted":false}'
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
@@ -848,7 +960,7 @@ function sourceSummary(target: TestTargetSpec, commit: string | null, context?:
|
|
|
|
|
repository: target.source.repository,
|
|
|
|
|
commitPolicy: target.source.commitPolicy,
|
|
|
|
|
commit,
|
|
|
|
|
images: context === undefined ? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage } : { api: context.apiImage, worker: context.workerImage },
|
|
|
|
|
images: context === undefined ? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage, migrationTemplate: target.source.migrationImage } : { api: context.apiImage, worker: context.workerImage, migration: context.migrationImage },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -899,13 +1011,14 @@ function namespaceAnnotations(target: TestTargetSpec, context: RenderContext): R
|
|
|
|
|
|
|
|
|
|
function objectSummary(object: Record<string, unknown>): Record<string, unknown> {
|
|
|
|
|
const metadata = optionalRecord(object.metadata, "metadata") ?? {};
|
|
|
|
|
const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {};
|
|
|
|
|
const spec = optionalRecord(object.spec, "spec") ?? {};
|
|
|
|
|
const selector = optionalRecord(spec.selector, "spec.selector") ?? {};
|
|
|
|
|
return {
|
|
|
|
|
kind: object.kind ?? null,
|
|
|
|
|
name: metadata.name ?? null,
|
|
|
|
|
namespace: metadata.namespace ?? null,
|
|
|
|
|
labels: metadata.labels ?? {},
|
|
|
|
|
ownershipLabelsPresent: labels["app.kubernetes.io/managed-by"] === MANAGED_BY && labels[TEST_RUNTIME_LABEL] === "true" && typeof labels[TARGET_LABEL] === "string" && typeof labels[INSTANCE_LABEL] === "string",
|
|
|
|
|
selector: selector.matchLabels ?? selector,
|
|
|
|
|
secretValuesRedacted: object.kind === "Secret" ? true : undefined,
|
|
|
|
|
};
|
|
|
|
@@ -1053,6 +1166,23 @@ function nullableString(value: unknown, path: string): string | null {
|
|
|
|
|
return nonEmpty(value, path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function absolutePath(value: unknown, path: string): string {
|
|
|
|
|
const result = nonEmpty(value, path);
|
|
|
|
|
if (!result.startsWith("/") || result.includes("\0")) throw new Error(`${path} 必须是绝对路径`);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nonEmptyStringList(value: unknown, path: string): string[] {
|
|
|
|
|
if (!Array.isArray(value) || value.length === 0) throw new Error(`${path} 必须是非空字符串数组`);
|
|
|
|
|
return value.map((item, index) => nonEmpty(item, `${path}[${index}]`));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function enumStringList<T extends string>(value: unknown, path: string, allowed: readonly T[]): T[] {
|
|
|
|
|
const items = nonEmptyStringList(value, path).map((item) => enumString(item, path, allowed));
|
|
|
|
|
if (new Set(items).size !== items.length) throw new Error(`${path} 不能包含重复值`);
|
|
|
|
|
return items;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function boolean(value: unknown, path: string): boolean {
|
|
|
|
|
if (typeof value !== "boolean") throw new Error(`${path} 必须是 boolean`);
|
|
|
|
|
return value;
|
|
|
|
@@ -1063,6 +1193,11 @@ function positiveInteger(value: unknown, path: string, max: number): number {
|
|
|
|
|
return Number(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nonNegativeInteger(value: unknown, path: string): number {
|
|
|
|
|
if (!Number.isSafeInteger(value) || Number(value) < 0) throw new Error(`${path} 必须是非负安全整数`);
|
|
|
|
|
return Number(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function enumString<T extends string>(value: unknown, path: string, allowed: readonly T[]): T {
|
|
|
|
|
if (typeof value !== "string" || !allowed.includes(value as T)) throw new Error(`${path} 必须是 ${allowed.join("、")}`);
|
|
|
|
|
return value as T;
|
|
|
|
|