Merge origin/master into feat/2010-hwlab-release-production-lane
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
metricsBackend:
|
||||
discovery:
|
||||
labelSelector:
|
||||
key: invalid label key
|
||||
value: "true"
|
||||
@@ -97,8 +97,9 @@ testRuntime:
|
||||
hostIP: 192.0.2.10
|
||||
port: 32080
|
||||
observability:
|
||||
otlpEndpoint: http://otel-collector.observability.svc.cluster.local:4318
|
||||
otlpEndpoint: otel-collector.observability.svc.cluster.local:4317
|
||||
prometheus:
|
||||
configRef: config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector
|
||||
scrape: true
|
||||
apiPath: /metrics
|
||||
workerPath: /metrics
|
||||
|
||||
@@ -211,30 +211,26 @@ lanes:
|
||||
codexStdioSupervisor: repo-owned
|
||||
kafkaEventBridge:
|
||||
enabled: true
|
||||
transactionalProjector:
|
||||
heartbeatIntervalMs: 2000
|
||||
projectionOutboxRelay:
|
||||
intervalMs: 250
|
||||
batchSize: 100
|
||||
leaseMs: 30000
|
||||
sendTimeoutMs: 10000
|
||||
retryBackoffMs: 1000
|
||||
projectionRealtime:
|
||||
outboxTailBatchSize: 100
|
||||
sseHeartbeatMs: 15000
|
||||
sseDrainTimeoutMs: 2500
|
||||
healthThresholds:
|
||||
consumerLagWarning: 0
|
||||
outboxBacklogWarning: 0
|
||||
retryCountWarning: 0
|
||||
failedInboxWarning: 0
|
||||
dlqWarning: 0
|
||||
features:
|
||||
directPublish: true
|
||||
liveKafkaSse: true
|
||||
kafkaRefreshReplay: true
|
||||
transactionalProjector: false
|
||||
projectionOutboxRelay: false
|
||||
projectionRealtime: false
|
||||
refreshReplay:
|
||||
groupIdPrefix: hwlab-v03-cloud-api-sse
|
||||
timeoutMs: 30000
|
||||
scanLimit: 1000000
|
||||
matchedEventLimit: 2000
|
||||
liveBufferLimit: 2000
|
||||
configRef: config/platform-infra/kafka.yaml#clients.hwlab-v03-cloud-api
|
||||
bootstrapServers: platform-infra-kafka-kafka-bootstrap.platform-infra.svc.cluster.local:9092
|
||||
stdioTopic: codex-stdio.raw.v1
|
||||
agentRunEventTopic: agentrun.event.v1
|
||||
hwlabEventTopic: hwlab.event.v1
|
||||
clientId: hwlab-v03-cloud-api
|
||||
directPublishConsumerGroupId: hwlab-v03-agentrun-event-direct-publish
|
||||
transactionalProjectorConsumerGroupId: hwlab-v03-agentrun-event-projector
|
||||
hwlabEventConsumerGroupId: hwlab-v03-workbench-live-sse
|
||||
publicExposure:
|
||||
|
||||
+3
-2
@@ -117,8 +117,9 @@ testRuntime:
|
||||
port: 32080
|
||||
observability:
|
||||
configRef: config/platform-infra/observability.yaml
|
||||
otlpEndpoint: http://otel-collector.platform-infra.svc.cluster.local:4318
|
||||
otlpEndpoint: otel-collector.platform-infra.svc.cluster.local:4317
|
||||
prometheus:
|
||||
configRef: config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector
|
||||
scrape: true
|
||||
apiPath: /metrics
|
||||
workerPath: /metrics
|
||||
@@ -228,7 +229,7 @@ delivery:
|
||||
configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-database-url
|
||||
observability:
|
||||
configRef: config/platform-infra/observability.yaml
|
||||
otlpHttpEndpoint: http://otel-collector.platform-infra.svc.cluster.local:4318
|
||||
otlpEndpoint: otel-collector.platform-infra.svc.cluster.local:4317
|
||||
prometheusScrape: true
|
||||
propagation:
|
||||
- tracecontext
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# R1.5.9.10 任务报告
|
||||
|
||||
## 状态
|
||||
|
||||
- 当前状态:`in_progress`。
|
||||
- 关联 issue:`pikasTech/unidesk#2092`。
|
||||
- 实现分支:`fix/pikaoa-observability`。
|
||||
- 本阶段仅完成源码、owning YAML、fixture 和定向验证;未执行任何运行面 mutation,未启动 Prometheus、Collector、Tempo 或 PikaOA Pod。
|
||||
- 后续仍需主代理通过受控 observability apply 完成 NC01 trace/metrics 原入口验收后再决定完成状态。
|
||||
|
||||
## 实现
|
||||
|
||||
- `config/pikaoa.yaml`:
|
||||
- 测试与生产 `otlpEndpoint` 统一为无 scheme 的 OTLP gRPC endpoint `otel-collector.platform-infra.svc.cluster.local:4317`;
|
||||
- 删除生产旧字段 `otlpHttpEndpoint`,不保留兼容入口;
|
||||
- 测试 Prometheus 新增 `configRef`,指向 `config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector`。
|
||||
- `scripts/src/pikaoa-test-target.ts`:
|
||||
- 复用共享 `scripts/src/ops/config-refs.ts` 的 `resolveConfigRef`;
|
||||
- 校验 configRef、OTLP gRPC endpoint 及 Kubernetes label key/value;
|
||||
- 将 selector label 加入 API/Worker Pod template,Web 不添加;
|
||||
- 在 plan/status 投影 selector 的 `sourceRef`、`presence`、`fingerprint` 和 `valuesPrinted=false`;
|
||||
- selector 漂移与 OTel exporter 失败均保持 `blocking=false` warning,不阻塞业务 MVP。
|
||||
- fixture 与测试:
|
||||
- 更新 endpoint 与 selector configRef;
|
||||
- 增加非法 label fixture;
|
||||
- 覆盖 endpoint、API/Worker selector label、Web 无 selector、configRef 缺失、引用路径错误、非法 Kubernetes label、旧生产字段删除及 `valuesPrinted=false`。
|
||||
|
||||
## 验证
|
||||
|
||||
- `bun --check scripts/src/pikaoa-test-target.ts`:通过。
|
||||
- `bun test scripts/src/pikaoa-test-target-async.test.ts`:`2 pass, 0 fail`。
|
||||
- `bun scripts/cli.ts check --syntax-only`:`11 passed, 0 failed`。
|
||||
- `bun scripts/cli.ts pikaoa test-target plan ...` compact text:通过,`mutation=false`、`valuesPrinted=false`;clean Artificer workspace 缺 fixture Secret 时仅显示既有非变更 blocker,不读取或打印 Secret,也不作为本任务运行面门禁。
|
||||
- `git diff --check`:通过。
|
||||
- `scripts/src/pikaoa-test-target.ts`:1910 行,低于 2000 行。
|
||||
|
||||
## 边界与后续
|
||||
|
||||
- 用户确认开发初期旧数据可丢;本任务不实现旧数据迁移、兼容或回滚,现有 migration 命名仅表示全新数据库初始化。
|
||||
- Prometheus 当前未 apply/ready 不阻塞业务 MVP;后续由主代理按受控入口完成 apply、Tempo trace 和 Prometheus metrics 验收。
|
||||
- 本报告回链 MDTODO `R1.5.9.10`,任务保持进行中。
|
||||
@@ -126,6 +126,9 @@
|
||||
##### R1.5.9.11 [in_progress]
|
||||
|
||||
修复合同迁移中 PL/pgSQL trigger function 的 Goose StatementBegin/StatementEnd 边界并完成 00001-00007 真实迁移验收(pikainc/pikaoa#20),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.11_Task_Report.md)。
|
||||
##### R1.5.9.12 [in_progress]
|
||||
|
||||
修复 PikaOA HTTP hostIP 测试入口因 crypto.randomUUID 不可用导致 Web 登录失败的问题,并完成桌面与移动端真实入口验收(pikainc/pikaoa#22),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.12_Task_Report.md)。
|
||||
### R1.6
|
||||
|
||||
审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。
|
||||
|
||||
@@ -62,6 +62,11 @@ function patchCodeAgentRuntimeWorkloads() {
|
||||
if (workloadName === "hwlab-cloud-api" && container.name === "hwlab-cloud-api") {
|
||||
fileChanged = patchCloudApiKafkaEnv(container, kafka) || fileChanged;
|
||||
}
|
||||
if (workloadName === "hwlab-cloud-web" && container.name === "hwlab-cloud-web") {
|
||||
fileChanged = setEnvValue(container, "HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", String(kafka.enabled && kafka.features.liveKafkaSse)) || fileChanged;
|
||||
fileChanged = setEnvValue(container, "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", String(kafka.enabled && kafka.features.kafkaRefreshReplay)) || fileChanged;
|
||||
fileChanged = setEnvValue(container, "HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", String(kafka.enabled && kafka.features.projectionRealtime)) || fileChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,24 +81,35 @@ function patchCodeAgentRuntimeWorkloads() {
|
||||
function patchCloudApiKafkaEnv(container, kafka) {
|
||||
let changed = false;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_ENABLED", String(kafka.enabled)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafka.enabled)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafka.enabled && (kafka.features.directPublish || kafka.features.transactionalProjector))) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED", String(kafka.enabled && kafka.features.directPublish)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", String(kafka.enabled && kafka.features.liveKafkaSse)) || changed;
|
||||
const refreshReplayEnabled = kafka.enabled && kafka.features.kafkaRefreshReplay;
|
||||
changed = setEnvValue(container, "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", String(refreshReplayEnabled)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED", String(kafka.enabled && kafka.features.transactionalProjector)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED", String(kafka.enabled && kafka.features.projectionOutboxRelay)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", String(kafka.enabled && kafka.features.projectionRealtime)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID", String(kafka.directPublishConsumerGroupId)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_PROJECTOR_GROUP_ID", String(kafka.transactionalProjectorConsumerGroupId)) || changed;
|
||||
changed = setEnvValue(container, "HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID", String(kafka.hwlabEventConsumerGroupId)) || changed;
|
||||
changed = patchOptionalEnvGroup(container, kafka.enabled, {
|
||||
HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS: kafka.transactionalProjector?.heartbeatIntervalMs,
|
||||
}) || changed;
|
||||
changed = patchOptionalEnvGroup(container, kafka.enabled, {
|
||||
HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS: kafka.projectionOutboxRelay?.intervalMs,
|
||||
HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE: kafka.projectionOutboxRelay?.batchSize,
|
||||
HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS: kafka.projectionOutboxRelay?.leaseMs,
|
||||
HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS: kafka.projectionOutboxRelay?.sendTimeoutMs,
|
||||
HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS: kafka.projectionOutboxRelay?.retryBackoffMs,
|
||||
}) || changed;
|
||||
changed = patchOptionalEnvGroup(container, kafka.enabled, {
|
||||
HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: kafka.projectionRealtime?.outboxTailBatchSize,
|
||||
HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: kafka.projectionRealtime?.sseHeartbeatMs,
|
||||
HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS: kafka.projectionRealtime?.sseDrainTimeoutMs,
|
||||
changed = patchOptionalEnvGroup(container, refreshReplayEnabled, {
|
||||
HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX: kafka.refreshReplay?.groupIdPrefix,
|
||||
HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS: kafka.refreshReplay?.timeoutMs,
|
||||
HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT: kafka.refreshReplay?.scanLimit,
|
||||
HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT: kafka.refreshReplay?.matchedEventLimit,
|
||||
HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT: kafka.refreshReplay?.liveBufferLimit,
|
||||
}) || changed;
|
||||
changed = removeEnvValues(container, [
|
||||
"HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS",
|
||||
"HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE",
|
||||
"HWLAB_WORKBENCH_SSE_HEARTBEAT_MS",
|
||||
"HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS",
|
||||
]) || changed;
|
||||
return changed;
|
||||
}
|
||||
|
||||
@@ -105,6 +121,13 @@ function patchOptionalEnvGroup(container, enabled, values) {
|
||||
return changed;
|
||||
}
|
||||
|
||||
function removeEnvValues(container, names) {
|
||||
const next = container.env.filter((item) => !names.includes(item?.name));
|
||||
if (next.length === container.env.length) return false;
|
||||
container.env = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setEnvValue(container, name, value) {
|
||||
const existing = container.env.find((item) => item?.name === name);
|
||||
if (existing?.value === value && existing.valueFrom === undefined) return false;
|
||||
|
||||
@@ -565,27 +565,20 @@ export interface HwlabRuntimeKafkaShadowProducerSpec {
|
||||
|
||||
export interface HwlabRuntimeKafkaEventBridgeSpec {
|
||||
readonly enabled: boolean;
|
||||
readonly transactionalProjector?: {
|
||||
readonly heartbeatIntervalMs: number;
|
||||
readonly features: {
|
||||
readonly directPublish: boolean;
|
||||
readonly liveKafkaSse: boolean;
|
||||
readonly kafkaRefreshReplay: boolean;
|
||||
readonly transactionalProjector: boolean;
|
||||
readonly projectionOutboxRelay: boolean;
|
||||
readonly projectionRealtime: boolean;
|
||||
};
|
||||
readonly projectionOutboxRelay?: {
|
||||
readonly intervalMs: number;
|
||||
readonly batchSize: number;
|
||||
readonly leaseMs: number;
|
||||
readonly sendTimeoutMs: number;
|
||||
readonly retryBackoffMs: number;
|
||||
};
|
||||
readonly projectionRealtime?: {
|
||||
readonly outboxTailBatchSize: number;
|
||||
readonly sseHeartbeatMs: number;
|
||||
readonly sseDrainTimeoutMs: number;
|
||||
};
|
||||
readonly healthThresholds: {
|
||||
readonly consumerLagWarning: number;
|
||||
readonly outboxBacklogWarning: number;
|
||||
readonly retryCountWarning: number;
|
||||
readonly failedInboxWarning: number;
|
||||
readonly dlqWarning: number;
|
||||
readonly refreshReplay?: {
|
||||
readonly groupIdPrefix: string;
|
||||
readonly timeoutMs: number;
|
||||
readonly scanLimit: number;
|
||||
readonly matchedEventLimit: number;
|
||||
readonly liveBufferLimit: number;
|
||||
};
|
||||
readonly configRef: string;
|
||||
readonly bootstrapServers: string;
|
||||
@@ -593,6 +586,7 @@ export interface HwlabRuntimeKafkaEventBridgeSpec {
|
||||
readonly agentRunEventTopic: string;
|
||||
readonly hwlabEventTopic: string;
|
||||
readonly clientId: string;
|
||||
readonly directPublishConsumerGroupId: string;
|
||||
readonly transactionalProjectorConsumerGroupId: string;
|
||||
readonly hwlabEventConsumerGroupId: string;
|
||||
}
|
||||
@@ -1251,75 +1245,55 @@ function codeAgentKafkaShadowProducerConfig(value: unknown, path: string): Hwlab
|
||||
|
||||
function codeAgentKafkaEventBridgeConfig(value: unknown, path: string): HwlabRuntimeKafkaEventBridgeSpec {
|
||||
const raw = asRecord(value, path);
|
||||
if (raw.refreshReplay !== undefined) throw new Error(`${path}.refreshReplay was removed with the fixed projection realtime architecture`);
|
||||
const enabled = booleanField(raw, "enabled", path);
|
||||
const transactionalProjectorConfig = raw.transactionalProjector === undefined
|
||||
const features = asRecord(raw.features, `${path}.features`);
|
||||
const liveKafkaSse = booleanField(features, "liveKafkaSse", `${path}.features`);
|
||||
const kafkaRefreshReplay = booleanField(features, "kafkaRefreshReplay", `${path}.features`);
|
||||
const refreshReplay = raw.refreshReplay === undefined
|
||||
? undefined
|
||||
: codeAgentKafkaTransactionalProjectorConfig(raw.transactionalProjector, `${path}.transactionalProjector`);
|
||||
const projectionOutboxRelayConfig = raw.projectionOutboxRelay === undefined
|
||||
? undefined
|
||||
: codeAgentKafkaProjectionOutboxRelayConfig(raw.projectionOutboxRelay, `${path}.projectionOutboxRelay`);
|
||||
const projectionRealtimeConfig = raw.projectionRealtime === undefined
|
||||
? undefined
|
||||
: codeAgentKafkaProjectionRealtimeConfig(raw.projectionRealtime, `${path}.projectionRealtime`);
|
||||
if (transactionalProjectorConfig === undefined) throw new Error(`${path}.transactionalProjector is required by the fixed transactional projection architecture`);
|
||||
if (projectionOutboxRelayConfig === undefined) throw new Error(`${path}.projectionOutboxRelay is required by the fixed transactional projection architecture`);
|
||||
if (projectionRealtimeConfig === undefined) throw new Error(`${path}.projectionRealtime is required by the fixed transactional projection architecture`);
|
||||
: codeAgentKafkaRefreshReplayConfig(raw.refreshReplay, `${path}.refreshReplay`);
|
||||
if (kafkaRefreshReplay && refreshReplay === undefined) {
|
||||
throw new Error(`${path}.refreshReplay is required when ${path}.features.kafkaRefreshReplay is true`);
|
||||
}
|
||||
if (kafkaRefreshReplay && !liveKafkaSse) {
|
||||
throw new Error(`${path}.features.liveKafkaSse must be true when ${path}.features.kafkaRefreshReplay is true`);
|
||||
}
|
||||
const directPublishConsumerGroupId = stringField(raw, "directPublishConsumerGroupId", path);
|
||||
const transactionalProjectorConsumerGroupId = stringField(raw, "transactionalProjectorConsumerGroupId", path);
|
||||
const hwlabEventConsumerGroupId = stringField(raw, "hwlabEventConsumerGroupId", path);
|
||||
if (transactionalProjectorConsumerGroupId === hwlabEventConsumerGroupId) {
|
||||
throw new Error(`${path} projector and realtime consumer group ids must be distinct`);
|
||||
if (new Set([directPublishConsumerGroupId, transactionalProjectorConsumerGroupId, hwlabEventConsumerGroupId]).size !== 3) {
|
||||
throw new Error(`${path} consumer group ids must be distinct so enabled capabilities receive independent event streams`);
|
||||
}
|
||||
return {
|
||||
enabled,
|
||||
transactionalProjector: transactionalProjectorConfig,
|
||||
projectionOutboxRelay: projectionOutboxRelayConfig,
|
||||
projectionRealtime: projectionRealtimeConfig,
|
||||
healthThresholds: codeAgentKafkaHealthThresholdsConfig(raw.healthThresholds, `${path}.healthThresholds`),
|
||||
enabled: booleanField(raw, "enabled", path),
|
||||
features: {
|
||||
directPublish: booleanField(features, "directPublish", `${path}.features`),
|
||||
liveKafkaSse,
|
||||
kafkaRefreshReplay,
|
||||
transactionalProjector: booleanField(features, "transactionalProjector", `${path}.features`),
|
||||
projectionOutboxRelay: booleanField(features, "projectionOutboxRelay", `${path}.features`),
|
||||
projectionRealtime: booleanField(features, "projectionRealtime", `${path}.features`),
|
||||
},
|
||||
...(refreshReplay === undefined ? {} : { refreshReplay }),
|
||||
configRef: stringField(raw, "configRef", path),
|
||||
bootstrapServers: stringField(raw, "bootstrapServers", path),
|
||||
stdioTopic: stringField(raw, "stdioTopic", path),
|
||||
agentRunEventTopic: stringField(raw, "agentRunEventTopic", path),
|
||||
hwlabEventTopic: stringField(raw, "hwlabEventTopic", path),
|
||||
clientId: stringField(raw, "clientId", path),
|
||||
directPublishConsumerGroupId,
|
||||
transactionalProjectorConsumerGroupId,
|
||||
hwlabEventConsumerGroupId,
|
||||
};
|
||||
}
|
||||
|
||||
function codeAgentKafkaTransactionalProjectorConfig(value: unknown, path: string): NonNullable<HwlabRuntimeKafkaEventBridgeSpec["transactionalProjector"]> {
|
||||
const raw = asRecord(value, path);
|
||||
return { heartbeatIntervalMs: numberField(raw, "heartbeatIntervalMs", path) };
|
||||
}
|
||||
|
||||
function codeAgentKafkaProjectionOutboxRelayConfig(value: unknown, path: string): NonNullable<HwlabRuntimeKafkaEventBridgeSpec["projectionOutboxRelay"]> {
|
||||
function codeAgentKafkaRefreshReplayConfig(value: unknown, path: string): NonNullable<HwlabRuntimeKafkaEventBridgeSpec["refreshReplay"]> {
|
||||
const raw = asRecord(value, path);
|
||||
return {
|
||||
intervalMs: numberField(raw, "intervalMs", path),
|
||||
batchSize: numberField(raw, "batchSize", path),
|
||||
leaseMs: numberField(raw, "leaseMs", path),
|
||||
sendTimeoutMs: numberField(raw, "sendTimeoutMs", path),
|
||||
retryBackoffMs: numberField(raw, "retryBackoffMs", path),
|
||||
};
|
||||
}
|
||||
|
||||
function codeAgentKafkaProjectionRealtimeConfig(value: unknown, path: string): NonNullable<HwlabRuntimeKafkaEventBridgeSpec["projectionRealtime"]> {
|
||||
const raw = asRecord(value, path);
|
||||
return {
|
||||
outboxTailBatchSize: numberField(raw, "outboxTailBatchSize", path),
|
||||
sseHeartbeatMs: numberField(raw, "sseHeartbeatMs", path),
|
||||
sseDrainTimeoutMs: numberField(raw, "sseDrainTimeoutMs", path),
|
||||
};
|
||||
}
|
||||
|
||||
function codeAgentKafkaHealthThresholdsConfig(value: unknown, path: string): HwlabRuntimeKafkaEventBridgeSpec["healthThresholds"] {
|
||||
const raw = asRecord(value, path);
|
||||
return {
|
||||
consumerLagWarning: nonNegativeIntegerField(raw, "consumerLagWarning", path),
|
||||
outboxBacklogWarning: nonNegativeIntegerField(raw, "outboxBacklogWarning", path),
|
||||
retryCountWarning: nonNegativeIntegerField(raw, "retryCountWarning", path),
|
||||
failedInboxWarning: nonNegativeIntegerField(raw, "failedInboxWarning", path),
|
||||
dlqWarning: nonNegativeIntegerField(raw, "dlqWarning", path),
|
||||
groupIdPrefix: stringField(raw, "groupIdPrefix", path),
|
||||
timeoutMs: numberField(raw, "timeoutMs", path),
|
||||
scanLimit: numberField(raw, "scanLimit", path),
|
||||
matchedEventLimit: numberField(raw, "matchedEventLimit", path),
|
||||
liveBufferLimit: numberField(raw, "liveBufferLimit", path),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1859,6 +1859,12 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s
|
||||
"function cloudWebRuntimeEnvEntries() {",
|
||||
" const exposure = overlay.publicExposure;",
|
||||
" const entries = !exposure || !Array.isArray(exposure.extraProxies) ? [] : exposure.extraProxies.filter((proxy) => proxy && proxy.cloudWebEnvName && proxy.publicBaseUrl).map((proxy) => ({ name: String(proxy.cloudWebEnvName), value: String(proxy.publicBaseUrl) }));",
|
||||
" const kafka = overlay.codeAgentRuntime && overlay.codeAgentRuntime.kafkaEventBridge;",
|
||||
" if (kafka) {",
|
||||
" entries.push({ name: 'HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED', value: String(kafka.enabled && kafka.features.liveKafkaSse) });",
|
||||
" entries.push({ name: 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED', value: String(kafka.enabled && kafka.features.kafkaRefreshReplay) });",
|
||||
" entries.push({ name: 'HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED', value: String(kafka.enabled && kafka.features.projectionRealtime) });",
|
||||
" }",
|
||||
" return entries;",
|
||||
"}",
|
||||
"function startupProbeFrom(probe) {",
|
||||
@@ -1956,35 +1962,31 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s
|
||||
" if (codeAgentRuntime.kafkaEventBridge) {",
|
||||
" const kafka = codeAgentRuntime.kafkaEventBridge;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_ENABLED', String(kafka.enabled)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled && (kafka.features.directPublish || kafka.features.transactionalProjector))) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_BOOTSTRAP_SERVERS', String(kafka.bootstrapServers)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_STDIO_TOPIC', String(kafka.stdioTopic)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC', String(kafka.agentRunEventTopic)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_EVENT_TOPIC', String(kafka.hwlabEventTopic)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_CLIENT_ID', String(kafka.clientId)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID', String(kafka.directPublishConsumerGroupId)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_PROJECTOR_GROUP_ID', String(kafka.transactionalProjectorConsumerGroupId)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID', String(kafka.hwlabEventConsumerGroupId)) || codeAgentRuntimeChanged;",
|
||||
" if (kafka.enabled) {",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS', String(kafka.transactionalProjector.heartbeatIntervalMs)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED', String(kafka.enabled && kafka.features.directPublish)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED', String(kafka.enabled && kafka.features.liveKafkaSse)) || codeAgentRuntimeChanged;",
|
||||
" const refreshReplayEnabled = kafka.enabled && kafka.features.kafkaRefreshReplay;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED', String(refreshReplayEnabled)) || codeAgentRuntimeChanged;",
|
||||
" if (refreshReplayEnabled) {",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX', String(kafka.refreshReplay.groupIdPrefix)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS', String(kafka.refreshReplay.timeoutMs)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT', String(kafka.refreshReplay.scanLimit)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT', String(kafka.refreshReplay.matchedEventLimit)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT', String(kafka.refreshReplay.liveBufferLimit)) || codeAgentRuntimeChanged;",
|
||||
" } else {",
|
||||
" codeAgentRuntimeChanged = removeEnvValues(container, ['HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS']) || codeAgentRuntimeChanged;",
|
||||
" }",
|
||||
" if (kafka.enabled) {",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS', String(kafka.projectionOutboxRelay.intervalMs)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE', String(kafka.projectionOutboxRelay.batchSize)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS', String(kafka.projectionOutboxRelay.leaseMs)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS', String(kafka.projectionOutboxRelay.sendTimeoutMs)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS', String(kafka.projectionOutboxRelay.retryBackoffMs)) || codeAgentRuntimeChanged;",
|
||||
" } else {",
|
||||
" codeAgentRuntimeChanged = removeEnvValues(container, ['HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS', 'HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE', 'HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS', 'HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS', 'HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS']) || codeAgentRuntimeChanged;",
|
||||
" }",
|
||||
" if (kafka.enabled) {",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE', String(kafka.projectionRealtime.outboxTailBatchSize)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_SSE_HEARTBEAT_MS', String(kafka.projectionRealtime.sseHeartbeatMs)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS', String(kafka.projectionRealtime.sseDrainTimeoutMs)) || codeAgentRuntimeChanged;",
|
||||
" } else {",
|
||||
" codeAgentRuntimeChanged = removeEnvValues(container, ['HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE', 'HWLAB_WORKBENCH_SSE_HEARTBEAT_MS', 'HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS']) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = removeEnvValues(container, ['HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT']) || codeAgentRuntimeChanged;",
|
||||
" }",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED', String(kafka.enabled && kafka.features.transactionalProjector)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED', String(kafka.enabled && kafka.features.projectionOutboxRelay)) || codeAgentRuntimeChanged;",
|
||||
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED', String(kafka.enabled && kafka.features.projectionRealtime)) || codeAgentRuntimeChanged;",
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
@@ -2331,6 +2333,12 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s
|
||||
"function cloudWebRuntimeEnvEntries() {",
|
||||
" const exposure = overlay.publicExposure;",
|
||||
" const entries = !exposure || !Array.isArray(exposure.extraProxies) ? [] : exposure.extraProxies.filter((proxy) => proxy && proxy.cloudWebEnvName && proxy.publicBaseUrl).map((proxy) => ({ name: String(proxy.cloudWebEnvName), value: String(proxy.publicBaseUrl) }));",
|
||||
" const kafka = overlay.codeAgentRuntime && overlay.codeAgentRuntime.kafkaEventBridge;",
|
||||
" if (kafka) {",
|
||||
" entries.push({ name: 'HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED', value: String(kafka.enabled && kafka.features.liveKafkaSse) });",
|
||||
" entries.push({ name: 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED', value: String(kafka.enabled && kafka.features.kafkaRefreshReplay) });",
|
||||
" entries.push({ name: 'HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED', value: String(kafka.enabled && kafka.features.projectionRealtime) });",
|
||||
" }",
|
||||
" return entries;",
|
||||
"}",
|
||||
"function workloadRef(item, file, container) { return { file, kind: item && item.kind, name: item && item.metadata && item.metadata.name, container: container && container.name }; }",
|
||||
@@ -2404,14 +2412,31 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s
|
||||
" if (codeAgentRuntime.kafkaEventBridge) {",
|
||||
" const kafka = codeAgentRuntime.kafkaEventBridge;",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_ENABLED', String(kafka.enabled));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled && (kafka.features.directPublish || kafka.features.transactionalProjector)));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_BOOTSTRAP_SERVERS', String(kafka.bootstrapServers));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_STDIO_TOPIC', String(kafka.stdioTopic));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC', String(kafka.agentRunEventTopic));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_EVENT_TOPIC', String(kafka.hwlabEventTopic));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_CLIENT_ID', String(kafka.clientId));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID', String(kafka.directPublishConsumerGroupId));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_PROJECTOR_GROUP_ID', String(kafka.transactionalProjectorConsumerGroupId));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID', String(kafka.hwlabEventConsumerGroupId));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED', String(kafka.enabled && kafka.features.directPublish));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED', String(kafka.enabled && kafka.features.liveKafkaSse));",
|
||||
" const refreshReplayEnabled = kafka.enabled && kafka.features.kafkaRefreshReplay;",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED', String(refreshReplayEnabled));",
|
||||
" if (refreshReplayEnabled) {",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX', String(kafka.refreshReplay.groupIdPrefix));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS', String(kafka.refreshReplay.timeoutMs));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT', String(kafka.refreshReplay.scanLimit));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT', String(kafka.refreshReplay.matchedEventLimit));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT', String(kafka.refreshReplay.liveBufferLimit));",
|
||||
" } else {",
|
||||
" for (const envName of ['HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT']) checkCodeAgentRuntimeAbsent(item, file, container, envName);",
|
||||
" }",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED', String(kafka.enabled && kafka.features.transactionalProjector));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED', String(kafka.enabled && kafka.features.projectionOutboxRelay));",
|
||||
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED', String(kafka.enabled && kafka.features.projectionRealtime));",
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
|
||||
@@ -1493,7 +1493,6 @@ export function nodeRuntimeCodeAgentRuntimeStatus(spec: HwlabRuntimeLaneSpec, na
|
||||
const apiKey = secretKeyStatus(spec, runtime.apiKeySecretName, runtime.apiKeySecretKey);
|
||||
const cloudApiEnv = nodeRuntimeCodeAgentCloudApiEnvStatus(spec, runtime);
|
||||
const cloudWebEnv = nodeRuntimeCodeAgentCloudWebEnvStatus(spec, runtime);
|
||||
const kafkaAuthority = nodeRuntimeKafkaAuthorityStatus(spec, runtime);
|
||||
const ready = runnerNamespace.exitCode === 0
|
||||
&& secretNamespace.exitCode === 0
|
||||
&& managerNamespace.exitCode === 0
|
||||
@@ -1550,73 +1549,12 @@ export function nodeRuntimeCodeAgentRuntimeStatus(spec: HwlabRuntimeLaneSpec, na
|
||||
apiKey,
|
||||
cloudApiEnv,
|
||||
cloudWebEnv,
|
||||
kafkaAuthority,
|
||||
repoUrlFrom: runtime.repoUrlFrom,
|
||||
providerIdFrom: runtime.providerIdFrom,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeRuntimeKafkaAuthorityStatus(spec: HwlabRuntimeLaneSpec, runtime: NonNullable<HwlabRuntimeLaneSpec["codeAgentRuntime"]>): Record<string, unknown> {
|
||||
const kafka = runtime.kafkaEventBridge;
|
||||
if (kafka === undefined || kafka.enabled === false) return { required: false, authority: kafka === undefined ? null : "transactional-projection", valuesPrinted: false };
|
||||
const healthUrl = `${spec.publicApiUrl.replace(/\/+$/u, "")}/health/ready`;
|
||||
const result = runCommand(["curl", "-k", "-sS", "--connect-timeout", "5", "--max-time", "12", healthUrl], repoRoot, { timeoutMs: 15_000 });
|
||||
let health: Record<string, unknown> = {};
|
||||
try {
|
||||
health = result.exitCode === 0 ? record(JSON.parse(result.stdout)) : {};
|
||||
} catch {
|
||||
health = {};
|
||||
}
|
||||
const projector = record(health.kafkaProjector);
|
||||
const components = Array.isArray(projector.components) ? projector.components.map(record) : [];
|
||||
const observations = [projector, ...components];
|
||||
const consumerLag = observations.map((item) => record(item.consumerLag).total).find((value) => typeof value === "number") ?? null;
|
||||
const backlogCount = observations.map((item) => item.backlogCount).find((value) => typeof value === "number") ?? null;
|
||||
const retryCount = observations.map((item) => item.retryCount).find((value) => typeof value === "number") ?? null;
|
||||
const failedInboxCount = observations.map((item) => item.failedInboxCount).find((value) => typeof value === "number") ?? null;
|
||||
const dlqCount = observations.map((item) => item.dlqCount).find((value) => typeof value === "number") ?? null;
|
||||
const thresholds = kafka.healthThresholds;
|
||||
const warningReasons = [
|
||||
typeof consumerLag === "number" && consumerLag > thresholds.consumerLagWarning ? "consumer-lag" : null,
|
||||
typeof backlogCount === "number" && backlogCount > thresholds.outboxBacklogWarning ? "outbox-backlog" : null,
|
||||
typeof retryCount === "number" && retryCount > thresholds.retryCountWarning ? "outbox-retry" : null,
|
||||
typeof failedInboxCount === "number" && failedInboxCount > thresholds.failedInboxWarning ? "failed-inbox" : null,
|
||||
typeof dlqCount === "number" && dlqCount > thresholds.dlqWarning ? "dlq" : null,
|
||||
].filter((value): value is string => value !== null);
|
||||
return {
|
||||
required: true,
|
||||
authority: "transactional-projection",
|
||||
fixedGroups: {
|
||||
transactionalProjector: kafka.transactionalProjectorConsumerGroupId,
|
||||
liveSse: kafka.hwlabEventConsumerGroupId,
|
||||
},
|
||||
capabilities: {
|
||||
transactionalProjector: true,
|
||||
projectionOutboxRelay: true,
|
||||
projectionRealtime: true,
|
||||
},
|
||||
liveReplay: {
|
||||
live: true,
|
||||
replay: true,
|
||||
cursor: "projection-outbox-seq",
|
||||
},
|
||||
healthObserved: Object.keys(projector).length > 0,
|
||||
healthStatus: projector.status ?? null,
|
||||
consumerLag,
|
||||
backlogCount,
|
||||
retryCount,
|
||||
failedInboxCount,
|
||||
dlqCount,
|
||||
thresholds,
|
||||
warning: warningReasons.length > 0,
|
||||
warningReasons,
|
||||
blocking: false,
|
||||
result: compactRuntimeCommand(result),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeRuntimeCodeAgentManagerTarget(managerUrl: string, fallbackNamespace: string): { namespace: string; serviceName: string } {
|
||||
let parsed: URL | null = null;
|
||||
try {
|
||||
@@ -1705,55 +1643,47 @@ function nodeRuntimeCodeAgentCloudApiEnvStatus(spec: HwlabRuntimeLaneSpec, runti
|
||||
const kafkaEventBridge = runtime.kafkaEventBridge;
|
||||
if (kafkaEventBridge !== undefined) {
|
||||
expectValue("HWLAB_KAFKA_ENABLED", String(kafkaEventBridge.enabled));
|
||||
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafkaEventBridge.enabled));
|
||||
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafkaEventBridge.enabled && (kafkaEventBridge.features.directPublish || kafkaEventBridge.features.transactionalProjector)));
|
||||
expectValue("HWLAB_KAFKA_BOOTSTRAP_SERVERS", kafkaEventBridge.bootstrapServers);
|
||||
expectValue("HWLAB_KAFKA_STDIO_TOPIC", kafkaEventBridge.stdioTopic);
|
||||
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC", kafkaEventBridge.agentRunEventTopic);
|
||||
expectValue("HWLAB_KAFKA_EVENT_TOPIC", kafkaEventBridge.hwlabEventTopic);
|
||||
expectValue("HWLAB_KAFKA_CLIENT_ID", kafkaEventBridge.clientId);
|
||||
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID", kafkaEventBridge.directPublishConsumerGroupId);
|
||||
expectValue("HWLAB_KAFKA_PROJECTOR_GROUP_ID", kafkaEventBridge.transactionalProjectorConsumerGroupId);
|
||||
expectValue("HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID", kafkaEventBridge.hwlabEventConsumerGroupId);
|
||||
if (kafkaEventBridge.enabled) {
|
||||
if (kafkaEventBridge.transactionalProjector === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.transactionalProjector is required when transactional projector is enabled");
|
||||
expectValue("HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS", String(kafkaEventBridge.transactionalProjector.heartbeatIntervalMs));
|
||||
} else {
|
||||
expectAbsent("HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS");
|
||||
}
|
||||
const relayEnvNames = [
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS",
|
||||
"HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS",
|
||||
expectValue("HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.directPublish));
|
||||
expectValue("HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.liveKafkaSse));
|
||||
const refreshReplayEnabled = kafkaEventBridge.enabled && kafkaEventBridge.features.kafkaRefreshReplay;
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", String(refreshReplayEnabled));
|
||||
const refreshReplayEnvNames = [
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT",
|
||||
];
|
||||
if (kafkaEventBridge.enabled) {
|
||||
const relay = kafkaEventBridge.projectionOutboxRelay;
|
||||
if (relay === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.projectionOutboxRelay is required when projection outbox relay is enabled");
|
||||
expectValue("HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS", String(relay.intervalMs));
|
||||
expectValue("HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE", String(relay.batchSize));
|
||||
expectValue("HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS", String(relay.leaseMs));
|
||||
expectValue("HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS", String(relay.sendTimeoutMs));
|
||||
expectValue("HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS", String(relay.retryBackoffMs));
|
||||
if (refreshReplayEnabled) {
|
||||
const refreshReplay = kafkaEventBridge.refreshReplay;
|
||||
if (refreshReplay === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.refreshReplay is required when Kafka refresh replay is enabled");
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX", refreshReplay.groupIdPrefix);
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS", String(refreshReplay.timeoutMs));
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT", String(refreshReplay.scanLimit));
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT", String(refreshReplay.matchedEventLimit));
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT", String(refreshReplay.liveBufferLimit));
|
||||
} else {
|
||||
for (const name of relayEnvNames) expectAbsent(name);
|
||||
}
|
||||
const projectionRealtimeEnvNames = ["HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE", "HWLAB_WORKBENCH_SSE_HEARTBEAT_MS", "HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS"];
|
||||
if (kafkaEventBridge.enabled) {
|
||||
const realtime = kafkaEventBridge.projectionRealtime;
|
||||
if (realtime === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.projectionRealtime is required when projection realtime is enabled");
|
||||
expectValue("HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE", String(realtime.outboxTailBatchSize));
|
||||
expectValue("HWLAB_WORKBENCH_SSE_HEARTBEAT_MS", String(realtime.sseHeartbeatMs));
|
||||
expectValue("HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS", String(realtime.sseDrainTimeoutMs));
|
||||
} else {
|
||||
for (const name of projectionRealtimeEnvNames) expectAbsent(name);
|
||||
for (const name of refreshReplayEnvNames) expectAbsent(name);
|
||||
}
|
||||
expectValue("HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.transactionalProjector));
|
||||
expectValue("HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.projectionOutboxRelay));
|
||||
expectValue("HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.projectionRealtime));
|
||||
}
|
||||
return {
|
||||
ready: mismatches.length === 0,
|
||||
deploymentExists: true,
|
||||
deploymentName: "hwlab-cloud-api",
|
||||
containerName: "hwlab-cloud-api",
|
||||
checkedEnvCount: 9 + (kafkaShadowProducer === undefined ? 0 : 5) + (kafkaEventBridge === undefined ? 0 : 22),
|
||||
checkedEnvCount: 9 + (kafkaShadowProducer === undefined ? 0 : 5) + (kafkaEventBridge === undefined ? 0 : 21),
|
||||
envCount: envByName.size,
|
||||
mismatches,
|
||||
result: compactCodeAgentDeploymentProbeResult(envProbe),
|
||||
@@ -1762,9 +1692,53 @@ function nodeRuntimeCodeAgentCloudApiEnvStatus(spec: HwlabRuntimeLaneSpec, runti
|
||||
}
|
||||
|
||||
function nodeRuntimeCodeAgentCloudWebEnvStatus(spec: HwlabRuntimeLaneSpec, runtime: NonNullable<HwlabRuntimeLaneSpec["codeAgentRuntime"]>): Record<string, unknown> {
|
||||
void spec;
|
||||
void runtime;
|
||||
return { required: false, ready: true, checkedEnvCount: 0, valuesPrinted: false };
|
||||
const kafkaEventBridge = runtime.kafkaEventBridge;
|
||||
if (kafkaEventBridge === undefined) return { required: false, ready: true, checkedEnvCount: 0, valuesPrinted: false };
|
||||
const envProbe = runNodeK3sArgs(spec, [
|
||||
"kubectl",
|
||||
"-n",
|
||||
spec.runtimeNamespace,
|
||||
"get",
|
||||
"deployment",
|
||||
"hwlab-cloud-web",
|
||||
"-o",
|
||||
'jsonpath={range .spec.template.spec.containers[?(@.name=="hwlab-cloud-web")].env[*]}{.name}{"\\t"}{.value}{"\\t"}{.valueFrom.secretKeyRef.name}{"\\t"}{.valueFrom.secretKeyRef.key}{"\\n"}{end}',
|
||||
], 60);
|
||||
if (envProbe.exitCode !== 0) {
|
||||
return {
|
||||
required: true,
|
||||
ready: false,
|
||||
deploymentExists: false,
|
||||
mismatches: [{ name: "hwlab-cloud-web", reason: "deployment-missing" }],
|
||||
result: compactCodeAgentDeploymentProbeResult(envProbe),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const envByName = parseCodeAgentEnvProbe(envProbe.stdout);
|
||||
const mismatches: Array<Record<string, unknown>> = [];
|
||||
const expectValue = (name: string, expected: string): void => {
|
||||
const item = envByName.get(name);
|
||||
if (item === undefined) {
|
||||
mismatches.push({ name, expected, present: false, kind: "value" });
|
||||
return;
|
||||
}
|
||||
if (item.value !== expected) mismatches.push({ name, expected, value: item.value, present: true, kind: "value" });
|
||||
};
|
||||
expectValue("HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.liveKafkaSse));
|
||||
expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.kafkaRefreshReplay));
|
||||
expectValue("HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.projectionRealtime));
|
||||
return {
|
||||
required: true,
|
||||
ready: mismatches.length === 0,
|
||||
deploymentExists: true,
|
||||
deploymentName: "hwlab-cloud-web",
|
||||
containerName: "hwlab-cloud-web",
|
||||
checkedEnvCount: 3,
|
||||
envCount: envByName.size,
|
||||
mismatches,
|
||||
result: compactCodeAgentDeploymentProbeResult(envProbe),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
type CodeAgentEnvProbeRow = {
|
||||
|
||||
@@ -162,13 +162,15 @@ exit 64
|
||||
return JSON.parse(result.renderedText) as Record<string, unknown>;
|
||||
};
|
||||
const waitFor = async (instance: string, expectedCode: string, step = "all"): Promise<Record<string, unknown>> => {
|
||||
let lastStatus: Record<string, unknown> | null = null;
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
const value = await projection("status", instance, false, step);
|
||||
const status = value.status as Record<string, unknown>;
|
||||
if (status.code === expectedCode) return value;
|
||||
lastStatus = status;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
assert.fail(`status did not reach ${expectedCode}`);
|
||||
assert.fail(`status did not reach ${expectedCode}: ${JSON.stringify(lastStatus)}`);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -178,9 +180,21 @@ exit 64
|
||||
const sources = secret.sources as Array<Record<string, unknown>>;
|
||||
assert.equal(sources.find((source) => source.purpose === "database.connection")?.presence, true);
|
||||
const objects = plan.objects as Array<Record<string, unknown>>;
|
||||
const observability = plan.observability as Record<string, unknown>;
|
||||
const prometheus = observability.prometheus as Record<string, unknown>;
|
||||
const selector = prometheus.selector as Record<string, unknown>;
|
||||
assert.equal(objects.some((object) => object.kind === "StatefulSet" || object.name === "pikaoa-postgres"), false);
|
||||
assert.equal(objects.some((object) => object.kind === "Deployment" && object.name === "pikaoa-web"), true);
|
||||
assert.equal(objects.find((object) => object.kind === "Deployment" && object.name === "pikaoa-api")?.podFsGroup, 65532);
|
||||
assert.equal(observability.otlpEndpoint, "otel-collector.observability.svc.cluster.local:4317");
|
||||
assert.equal(selector.sourceRef, "config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector");
|
||||
assert.equal(selector.presence, true);
|
||||
assert.match(String(selector.fingerprint), /^sha256:[0-9a-f]{64}$/u);
|
||||
assert.equal(selector.valuesPrinted, false);
|
||||
for (const component of ["pikaoa-api", "pikaoa-worker"]) {
|
||||
assert.equal(objects.find((object) => object.kind === "Deployment" && object.name === component)?.prometheusSelectorLabel, selector.value);
|
||||
}
|
||||
assert.equal(objects.find((object) => object.kind === "Deployment" && object.name === "pikaoa-web")?.prometheusSelectorLabel, undefined);
|
||||
assert.deepEqual(plan.exposure, { serviceType: "NodePort", serviceName: "pikaoa-web", hostIP: "192.0.2.10", port: 32080 });
|
||||
assert.deepEqual(plan.webApiUpstream, { envName: "PIKAOA_API_UPSTREAM", value: "http://pikaoa-api:8080", scope: "same-namespace" });
|
||||
|
||||
@@ -387,6 +401,7 @@ test("validation-only fixture never invokes remote capture", async () => {
|
||||
valuesPrinted: false,
|
||||
});
|
||||
assert.equal(planPayload.valuesPrinted, false);
|
||||
assert.equal(((planPayload.observability as Record<string, unknown>).prometheus as Record<string, unknown>).selector !== undefined, true);
|
||||
assert.equal(JSON.stringify(planPayload).includes("fixture-db-password"), false);
|
||||
assert.equal(JSON.stringify(planPayload).includes("postgres://"), false);
|
||||
|
||||
@@ -407,6 +422,54 @@ test("validation-only fixture never invokes remote capture", async () => {
|
||||
rmSync(missingSchemaRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const missingSelectorRoot = mkdtempSync(join(tmpdir(), "pikaoa-test-target-missing-selector-"));
|
||||
const missingSelectorPath = join(missingSelectorRoot, "pikaoa.yaml");
|
||||
const fixture = readFileSync(rootPath("config", "fixtures", "pikaoa-test-target.yaml"), "utf8");
|
||||
writeFileSync(missingSelectorPath, fixture.replace(" configRef: config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector\n", ""));
|
||||
try {
|
||||
await assert.rejects(
|
||||
runPikaoaCommand({} as UniDeskConfig, ["test-target", "status", "--config", missingSelectorPath, "--target", "TEST01", "--instance", "missing-selector", "--output", "json"], forbiddenCapture),
|
||||
/observability\.prometheus\.configRef 必须是非空字符串/u,
|
||||
);
|
||||
} finally {
|
||||
rmSync(missingSelectorRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const invalidSelectorRoot = mkdtempSync(join(tmpdir(), "pikaoa-test-target-invalid-selector-"));
|
||||
const invalidSelectorPath = join(invalidSelectorRoot, "pikaoa.yaml");
|
||||
writeFileSync(invalidSelectorPath, fixture.replace(
|
||||
"config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector",
|
||||
"config/fixtures/pikaoa-observability-invalid-label.yaml#metricsBackend.discovery.labelSelector",
|
||||
));
|
||||
try {
|
||||
await assert.rejects(
|
||||
runPikaoaCommand({} as UniDeskConfig, ["test-target", "status", "--config", invalidSelectorPath, "--target", "TEST01", "--instance", "invalid-selector", "--output", "json"], forbiddenCapture),
|
||||
/observability\.prometheus\.configRef\.key 必须是合法 Kubernetes label key/u,
|
||||
);
|
||||
} finally {
|
||||
rmSync(invalidSelectorRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const missingSelectorPathRoot = mkdtempSync(join(tmpdir(), "pikaoa-test-target-missing-selector-path-"));
|
||||
const missingSelectorConfigPath = join(missingSelectorPathRoot, "pikaoa.yaml");
|
||||
writeFileSync(missingSelectorConfigPath, fixture.replace("metricsBackend.discovery.labelSelector", "metricsBackend.discovery.missingSelector"));
|
||||
try {
|
||||
await assert.rejects(
|
||||
runPikaoaCommand({} as UniDeskConfig, ["test-target", "status", "--config", missingSelectorConfigPath, "--target", "TEST01", "--instance", "missing-selector-path", "--output", "json"], forbiddenCapture),
|
||||
/observability\.prometheus\.configRef 解析失败.*missingSelector/u,
|
||||
);
|
||||
} finally {
|
||||
rmSync(missingSelectorPathRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const owningConfig = Bun.YAML.parse(readFileSync(rootPath("config", "pikaoa.yaml"), "utf8")) as Record<string, unknown>;
|
||||
const testTarget = (((owningConfig.testRuntime as Record<string, unknown>).targets as Record<string, unknown>).NC01 as Record<string, unknown>);
|
||||
assert.equal((testTarget.observability as Record<string, unknown>).otlpEndpoint, "otel-collector.platform-infra.svc.cluster.local:4317");
|
||||
const production = (((owningConfig.delivery as Record<string, unknown>).targets as Record<string, unknown>).NC01 as Record<string, unknown>);
|
||||
const productionObservability = (production.runtime as Record<string, unknown>).observability as Record<string, unknown>;
|
||||
assert.equal(productionObservability.otlpEndpoint, "otel-collector.platform-infra.svc.cluster.local:4317");
|
||||
assert.equal("otlpHttpEndpoint" in productionObservability, false);
|
||||
|
||||
const unconfiguredRoot = mkdtempSync(join(tmpdir(), "pikaoa-test-target-unconfigured-"));
|
||||
const unconfiguredPath = join(unconfiguredRoot, "pikaoa.yaml");
|
||||
writeFileSync(unconfiguredPath, "version: 1\nkind: pikaoa-platform-delivery\ntestRuntime:\n defaultTargetId: null\n targets: {}\n");
|
||||
|
||||
@@ -9,6 +9,7 @@ import { renderMachine } from "./cicd-render";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
import { CliInputError } from "./output";
|
||||
import { capture, compactCapture, parseJsonOutput, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library";
|
||||
import { resolveConfigRef } from "./ops/config-refs";
|
||||
|
||||
type TestTargetAction = "plan" | "status" | "start" | "stop";
|
||||
type TestTargetStep = "all" | "foundation" | "migration" | "api" | "worker" | "web";
|
||||
@@ -31,6 +32,15 @@ interface SecretSourceSpec {
|
||||
targetKey: string;
|
||||
}
|
||||
|
||||
interface PrometheusSelectorSpec {
|
||||
sourceRef: string;
|
||||
key: string;
|
||||
value: string;
|
||||
presence: true;
|
||||
fingerprint: string;
|
||||
valuesPrinted: false;
|
||||
}
|
||||
|
||||
interface TestTargetSpec {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
@@ -107,6 +117,7 @@ interface TestTargetSpec {
|
||||
observability: {
|
||||
otlpEndpoint: string;
|
||||
prometheusScrape: boolean;
|
||||
prometheusSelector: PrometheusSelectorSpec;
|
||||
apiMetricsPath: string;
|
||||
workerMetricsPath: string;
|
||||
exporterWarning: boolean;
|
||||
@@ -387,8 +398,9 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
|
||||
port: positiveInteger(exposure.port, `${path}.exposure.port`, 65_535),
|
||||
},
|
||||
observability: {
|
||||
otlpEndpoint: nonEmpty(observability.otlpEndpoint, `${path}.observability.otlpEndpoint`),
|
||||
otlpEndpoint: otlpGrpcEndpoint(observability.otlpEndpoint, `${path}.observability.otlpEndpoint`),
|
||||
prometheusScrape: boolean(prometheus.scrape, `${path}.observability.prometheus.scrape`),
|
||||
prometheusSelector: prometheusSelector(prometheus.configRef, `${path}.observability.prometheus`),
|
||||
apiMetricsPath: httpPath(prometheus.apiPath, `${path}.observability.prometheus.apiPath`),
|
||||
workerMetricsPath: httpPath(prometheus.workerPath, `${path}.observability.prometheus.workerPath`),
|
||||
exporterWarning: boolean(exporterFailure.warning, `${path}.observability.exporterFailure.warning`),
|
||||
@@ -439,6 +451,7 @@ function planPayload(options: TestTargetOptions, selection: Selection): Record<s
|
||||
missingFields: missingMutationFields(options, selection),
|
||||
blockers,
|
||||
warnings,
|
||||
observability: observabilitySummary(selection.target),
|
||||
plan: renderWorkloads && context !== null ? renderedPlan(selection, context) : null,
|
||||
next: nextCommands(options, selection.target),
|
||||
};
|
||||
@@ -482,7 +495,7 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
|
||||
ttlSeconds: target.ttlSeconds,
|
||||
cleanup: target.cleanup,
|
||||
},
|
||||
objects: structuralManifest.map(objectSummary),
|
||||
objects: structuralManifest.map((object) => objectSummary(object, target.observability.prometheusSelector.key)),
|
||||
selectors,
|
||||
secret: {
|
||||
name: target.runtime.secretName,
|
||||
@@ -504,8 +517,7 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
|
||||
web: { liveness: target.probes.webLivenessPath, readiness: target.probes.webReadinessPath },
|
||||
},
|
||||
observability: {
|
||||
otlpEndpoint: target.observability.otlpEndpoint,
|
||||
prometheusScrape: target.observability.prometheusScrape,
|
||||
...observabilitySummary(target),
|
||||
exporterFailure: { warning: target.observability.exporterWarning, blocking: false },
|
||||
},
|
||||
exposure: { serviceType: target.exposure.serviceType, serviceName: target.exposure.serviceName, hostIP: target.exposure.hostIP, port: target.exposure.port },
|
||||
@@ -670,6 +682,41 @@ function consistencyWarnings(target: TestTargetSpec, context: RenderContext | nu
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function observabilitySummary(target: TestTargetSpec): Record<string, unknown> {
|
||||
return {
|
||||
otlpEndpoint: target.observability.otlpEndpoint,
|
||||
prometheus: {
|
||||
scrape: target.observability.prometheusScrape,
|
||||
selector: target.observability.prometheusSelector,
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function prometheusRuntimeStatus(items: Record<string, unknown>[], selector: PrometheusSelectorSpec, runtimePresent: boolean, step: TestTargetStep): Record<string, unknown> {
|
||||
const components = step === "all" ? ["api", "worker"] : step === "api" || step === "worker" ? [step] : [];
|
||||
const workloads = components.map((component) => {
|
||||
const deployment = items.find((item) => item.kind === "Deployment" && optionalRecord(item.metadata, "resource.metadata")?.name === `pikaoa-${component}`);
|
||||
const spec = deployment === undefined ? null : optionalRecord(deployment.spec, "resource.spec");
|
||||
const template = spec === null ? null : optionalRecord(spec.template, "resource.spec.template");
|
||||
const metadata = template === null ? null : optionalRecord(template.metadata, "resource.spec.template.metadata");
|
||||
const labels = metadata === null ? null : optionalRecord(metadata.labels, "resource.spec.template.metadata.labels");
|
||||
const actual = labels?.[selector.key];
|
||||
return { component, present: deployment !== undefined, actual: typeof actual === "string" ? actual : null, matches: actual === selector.value };
|
||||
});
|
||||
const checked = runtimePresent && components.length > 0;
|
||||
const drift = checked && workloads.some((workload) => !workload.matches);
|
||||
return {
|
||||
selector,
|
||||
checked,
|
||||
workloads,
|
||||
drift,
|
||||
warning: drift ? warning("prometheus-selector-label-drift", "API/Worker Pod template 的 Prometheus selector label 与 owning configRef 不一致;该漂移不阻塞业务运行。") : null,
|
||||
blocking: false,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record<string, unknown>[] {
|
||||
const labels = ownershipLabels(target, context);
|
||||
const secretData = runtimeSecretData(target, context, secrets);
|
||||
@@ -677,6 +724,9 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets:
|
||||
const workerSelector = componentSelector("worker", context.instanceId);
|
||||
const webSelector = componentSelector("web", context.instanceId);
|
||||
const migrationSelector = componentSelector("migration", context.instanceId);
|
||||
const prometheusPodLabels = target.observability.prometheusScrape ? {
|
||||
[target.observability.prometheusSelector.key]: target.observability.prometheusSelector.value,
|
||||
} : {};
|
||||
const commonPodAnnotations = (path: string, port: number): Record<string, string> => target.observability.prometheusScrape ? {
|
||||
"prometheus.io/scrape": "true",
|
||||
"prometheus.io/path": path,
|
||||
@@ -728,7 +778,7 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets:
|
||||
},
|
||||
},
|
||||
},
|
||||
deployment(target, context, "api", context.apiImage, apiSelector, target.runtime.apiPort, commonPodAnnotations(target.observability.apiMetricsPath, target.runtime.apiPort), [
|
||||
deployment(target, context, "api", context.apiImage, apiSelector, target.runtime.apiPort, prometheusPodLabels, 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 } } },
|
||||
{ name: "PIKAOA_BOOTSTRAP_ADMIN_USERNAME", value: target.runtime.administrator.username },
|
||||
@@ -736,10 +786,10 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets:
|
||||
{ name: "PIKAOA_BOOTSTRAP_EMPLOYEE_USERNAME", value: target.runtime.employee.username },
|
||||
{ name: "PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.employee.password.targetKey } } },
|
||||
], target.probes.apiLivenessPath, target.probes.apiReadinessPath),
|
||||
deployment(target, context, "worker", context.workerImage, workerSelector, target.runtime.workerMetricsPort, commonPodAnnotations(target.observability.workerMetricsPath, target.runtime.workerMetricsPort), [
|
||||
deployment(target, context, "worker", context.workerImage, workerSelector, target.runtime.workerMetricsPort, prometheusPodLabels, commonPodAnnotations(target.observability.workerMetricsPath, target.runtime.workerMetricsPort), [
|
||||
{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" },
|
||||
], target.probes.workerLivenessPath, target.probes.workerReadinessPath),
|
||||
deployment(target, context, "web", context.webImage, webSelector, target.runtime.webPort, {}, [
|
||||
deployment(target, context, "web", context.webImage, webSelector, target.runtime.webPort, {}, {}, [
|
||||
{ name: target.runtime.webApiUpstreamEnv, value: `http://${target.runtime.apiServiceName}:${target.runtime.apiPort}` },
|
||||
], target.probes.webLivenessPath, target.probes.webReadinessPath),
|
||||
];
|
||||
@@ -765,6 +815,7 @@ function deployment(
|
||||
image: string,
|
||||
selector: Record<string, string>,
|
||||
port: number,
|
||||
podLabels: Record<string, string>,
|
||||
annotations: Record<string, string>,
|
||||
env: Array<Record<string, unknown>>,
|
||||
livenessPath: string,
|
||||
@@ -776,7 +827,7 @@ function deployment(
|
||||
spec: {
|
||||
replicas: 1, selector: { matchLabels: selector },
|
||||
template: {
|
||||
metadata: { labels: { ...labels, ...selector }, annotations },
|
||||
metadata: { labels: { ...labels, ...selector, ...podLabels }, annotations },
|
||||
spec: {
|
||||
...(component === "api" ? { securityContext: { fsGroup: target.runtime.attachment.fsGroup } } : {}),
|
||||
containers: [{
|
||||
@@ -1419,6 +1470,7 @@ function summarizeRemoteStatus(parsed: Record<string, unknown>, target: TestTarg
|
||||
const labels = optionalRecord(metadata.labels, "status.namespace.metadata.labels") ?? {};
|
||||
const resources = optionalRecord(runtime.resources, "status.runtime.resources") ?? {};
|
||||
const items = Array.isArray(resources.items) ? resources.items.filter(isRecord) : [];
|
||||
const prometheus = prometheusRuntimeStatus(items, target.observability.prometheusSelector, runtime.exists === true, context.step);
|
||||
return {
|
||||
ok: parsed.ok === true,
|
||||
code: parsed.code ?? "start-status-unknown",
|
||||
@@ -1453,7 +1505,7 @@ function summarizeRemoteStatus(parsed: Record<string, unknown>, target: TestTarg
|
||||
}),
|
||||
database: { external: true, configRef: target.database.configRef, ...secretSourceSummary(configPath, target.database.connection) },
|
||||
exposure: { hostIP: target.exposure.hostIP, port: target.exposure.port, serviceName: target.exposure.serviceName },
|
||||
observability: { otlpEndpoint: target.observability.otlpEndpoint, prometheusScrape: target.observability.prometheusScrape, blocking: false },
|
||||
observability: { ...observabilitySummary(target), prometheus, blocking: false },
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
@@ -1566,17 +1618,20 @@ function namespaceAnnotations(target: TestTargetSpec, context: RenderContext): R
|
||||
};
|
||||
}
|
||||
|
||||
function objectSummary(object: Record<string, unknown>): Record<string, unknown> {
|
||||
function objectSummary(object: Record<string, unknown>, prometheusSelectorKey: string): Record<string, unknown> {
|
||||
const metadata = optionalRecord(object.metadata, "metadata") ?? {};
|
||||
const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {};
|
||||
const spec = optionalRecord(object.spec, "spec") ?? {};
|
||||
const template = optionalRecord(spec.template, "spec.template") ?? {};
|
||||
const templateMetadata = optionalRecord(template.metadata, "spec.template.metadata") ?? {};
|
||||
const podLabels = optionalRecord(templateMetadata.labels, "spec.template.metadata.labels") ?? {};
|
||||
const podSpec = optionalRecord(template.spec, "spec.template.spec") ?? {};
|
||||
const securityContext = optionalRecord(podSpec.securityContext, "spec.template.spec.securityContext") ?? {};
|
||||
return {
|
||||
kind: object.kind ?? null,
|
||||
name: metadata.name ?? null,
|
||||
owned: labels["app.kubernetes.io/managed-by"] === MANAGED_BY && labels[TEST_RUNTIME_LABEL] === "true" && typeof labels[TARGET_LABEL] === "string" && typeof labels[INSTANCE_LABEL] === "string",
|
||||
prometheusSelectorLabel: object.kind === "Deployment" && typeof podLabels[prometheusSelectorKey] === "string" ? podLabels[prometheusSelectorKey] : undefined,
|
||||
podFsGroup: securityContext.fsGroup,
|
||||
secretValuesRedacted: object.kind === "Secret" ? true : undefined,
|
||||
};
|
||||
@@ -1705,6 +1760,43 @@ function imageReference(value: unknown, path: string): string {
|
||||
return image;
|
||||
}
|
||||
|
||||
function otlpGrpcEndpoint(value: unknown, path: string): string {
|
||||
const endpoint = nonEmpty(value, path);
|
||||
if (endpoint.includes("://") || !/^[A-Za-z0-9.-]+:[1-9][0-9]{0,4}$/u.test(endpoint)) throw new Error(`${path} 必须是无 scheme 的 OTLP gRPC host:port`);
|
||||
const port = Number(endpoint.slice(endpoint.lastIndexOf(":") + 1));
|
||||
if (port > 65_535) throw new Error(`${path} 端口必须是 1-65535`);
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
function prometheusSelector(value: unknown, path: string): PrometheusSelectorSpec {
|
||||
const sourceRef = nonEmpty(value, `${path}.configRef`);
|
||||
let resolved: ReturnType<typeof resolveConfigRef>;
|
||||
try {
|
||||
resolved = resolveConfigRef(sourceRef, `${path}.configRef`);
|
||||
} catch (error) {
|
||||
throw new Error(`${path}.configRef 解析失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
const selector = record(resolved.value, `${path}.configRef (${sourceRef})`);
|
||||
const key = kubernetesLabelKey(nonEmpty(selector.key, `${path}.configRef.key`), `${path}.configRef.key`);
|
||||
const selectorValue = kubernetesLabelValue(nonEmpty(selector.value, `${path}.configRef.value`), `${path}.configRef.value`);
|
||||
return { sourceRef, key, value: selectorValue, presence: true, fingerprint: `sha256:${resolved.sha256}`, valuesPrinted: false };
|
||||
}
|
||||
|
||||
function kubernetesLabelKey(value: string, path: string): string {
|
||||
const parts = value.split("/");
|
||||
const name = parts.pop() ?? "";
|
||||
const prefix = parts.length === 1 ? parts[0] : null;
|
||||
const labelNamePattern = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u;
|
||||
const dnsPrefixPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/u;
|
||||
if (parts.length > 1 || !labelNamePattern.test(name) || (prefix !== null && !dnsPrefixPattern.test(prefix))) throw new Error(`${path} 必须是合法 Kubernetes label key`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function kubernetesLabelValue(value: string, path: string): string {
|
||||
if (!/^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u.test(value)) throw new Error(`${path} 必须是合法 Kubernetes label value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function postgresIdentifier(value: unknown, path: string): string {
|
||||
const name = nonEmpty(value, path);
|
||||
if (!/^[a-z_][a-z0-9_]{0,62}$/u.test(name)) throw new Error(`${path} 必须是简单 PostgreSQL identifier`);
|
||||
|
||||
Reference in New Issue
Block a user