Merge pull request #2723 from pikasTech/fix/2721-nc01-ci-resource-governance
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

fix(infra): 治理 NC01 CI 资源争抢
This commit is contained in:
Lyon
2026-07-21 11:25:19 +08:00
committed by GitHub
19 changed files with 707 additions and 81 deletions
+35
View File
@@ -61,8 +61,12 @@ lanes:
- hwlab-workbench-runtime
- hwlab-user-billing
- hwlab-project-management
- hwlab-tasktree-api
- hwlab-tasktree-worker
- hwlab-harnessrl-api
- hwlab-harnessrl-worker
- hwlab-workbench-api
- hwlab-workbench-worker
- hwlab-hwpod-api
- hwlab-hwpod-worker
- hwlab-hwpod-web
@@ -314,6 +318,37 @@ lanes:
buildkit:
sidecarImage: 127.0.0.1:5000/hwlab/buildkit:rootless
sourceImage: docker.io/moby/buildkit:rootless
ciResourceGovernance:
build:
matrixTaskName: build-services
maxParallelServices: 2
taskTimeout: 20m
priorityClassName: unidesk-ci-batch
stepResources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 750m
memory: 768Mi
sidecarResources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1500m
memory: 1536Mi
dedupe:
identityFields:
- repository
- lane
- sourceCommit
- pipelineIntent
coreProtection:
configRefs:
- config/hwlab-node-control-plane.yaml#targets.NC01.k3s
- config/platform-infra/sub2api.yaml#targets.NC01.resourceProtection
- config/platform-infra/public-edge.yaml#targets.NC01.runtime.resourceProtection
stepEnv:
HOME: /tekton/home
XDG_CONFIG_HOME: /tekton/home/.config
+4
View File
@@ -52,6 +52,10 @@ targets:
- h2
runtime:
image: caddy:2.10.2-alpine
resourceProtection:
cpuShares: 2048
memoryReservation: 128m
memoryLimit: 512m
containerName: unidesk-public-edge
workDir: /opt/unidesk/public-edge
composePath: /opt/unidesk/public-edge/docker-compose.yaml
+19
View File
@@ -251,6 +251,25 @@ targets:
redisReplicas: 1
runtime:
autoSetup: false
resourceProtection:
priorityClassName: unidesk-platform-core
priorityValue: 1000000
batchPriorityClassName: unidesk-ci-batch
batchPriorityValue: -1000000
app:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 2000m
memory: 2Gi
redis:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 512Mi
image:
repository: weishaw/sub2api
tag: 0.1.162
@@ -85,6 +85,7 @@
| PJ2026-01060104 | 验证分层 | 本规格 6.4 | 自测试、综合联调、CLI/API 交互和真实运行面通过口径 | 目标 runtime、业务模块测试需求 | 发布决策 |
| PJ2026-01060105 | AgentRun发布 | [PJ2026-01060105 AgentRun发布Lane](PJ2026-01060105-agentrun-v01-release-lane.md) | AgentRun `v0.1` 的发布 lane、Pipeline、runtime namespace 和真实联调细则 | 源码同步、YAML运维、Agent编排 | AgentRun runtime |
| PJ2026-01060106 | HWLAB双环境 | [PJ2026-01060106 HWLAB双环境](PJ2026-01060106-hwlab-dev-production-lanes.md) | HWLAB `v0.3` development 与 `release` production 的分支、流水线、公开入口和数据隔离 | 源码同步、YAML运维、Workbench实时权威 | HWLAB runtime |
| PJ2026-01060107 | NC01 CI资源治理 | [PJ2026-01060107 NC01 CI资源治理](PJ2026-01060107-nc01-ci-resource-governance.md) | NC01 构建并发、排队、资源合同、稳定去重和核心运行面保护 | 发布流水、YAML运维、CI/CD目标治理 | NC01 CI 与核心服务 |
## 6. 原子需求
@@ -0,0 +1,93 @@
# PJ2026-01060107 NC01 CI资源治理
## 修改历史
| 版本 | 对应 commit id | 更新日期 | 变更说明 |
| --- | --- | --- | --- |
当前正文仍在规格治理草稿中;未定稿前不新增版本号,不为单次编辑追加 `待提交` 版本。
## 正文
## PJ2026-01060107 NC01 CI资源治理需求规格
## 1. 文档控制
| 字段 | 内容 |
| --- | --- |
| 编号 | PJ2026-01060107 |
| 短名 | NC01 CI资源治理 |
| 层级 | L3 子课题 |
| 状态 | 草稿 |
| 实现引用版本 | draft-2026-07-21-p0-nc01-ci-resource-governance |
| 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) |
| 上级规格 | [PJ2026-010601 发布流水](PJ2026-010601-controlled-release.md) |
| 关联规格 | [PJ2026-010603 YAML运维](PJ2026-010603-yaml-first-ops.md)、[PJ2026-01060308 CI/CD YAML-first目标治理](PJ2026-01060308-cicd-yaml-first-target-governance.md) |
| 跟踪任务 | pikasTech/unidesk#2721、TaskTree `tg_743359ba-58aa-426b-a6f0-4df84fb1d795` |
本文定义 NC01 上 CI 构建并发、排队、资源合同、稳定去重和核心运行面保护。所有可调事实由 owning YAML 控制,代码只校验、渲染和观察;禁止手改 Pipeline、PipelineRun、TaskRun、Pod 或运行中 workload 来补齐合同。
## 2. 事故与目标
单个 HWLAB PipelineRun 曾同时展开 14 个 `build-services` TaskRun,使 8 核 NC01 的 load、Swap 和 k3s Kine/SQLite 延迟同时上升,并造成核心 API 约四分钟 502。PaC Repository 的 `concurrencyLimit` 只限制 Repository 触发层的并发 PipelineRun,不能限制一个 PipelineRun 内 matrix 展开的 TaskRun 数量。
治理目标是让每个发布意图只产生一个受控运行,并让单次运行中的构建工作按 YAML 预算进入有界 worker 队列。CI 压力下,k3s 控制面、CoreDNS、Sub2API 和 Public Edge 的可用性优先于构建吞吐。
## 3. 架构与数据流
```mermaid
flowchart LR
Y[owning YAML] --> V[配置校验]
V --> R[HWLAB Pipeline renderer]
R --> D[单 TaskRun 有界 worker 队列]
D --> T[Tekton TaskRun/Pod]
T --> K[Kubernetes 调度]
T --> O[plan/status/diagnosis]
K --> C[核心运行面保护]
```
Repository 触发排队和 Pipeline 内构建排队是两个独立层次。前者继续由 PaC Repository `concurrencyLimit` 管理,后者由 renderer 将目标 matrix 收敛为一个受资源约束的 TaskRun,并在 Pod 内按 `maxParallelServices` 执行有界 worker 队列。不得复制大型内联 `taskSpec`,不得把二者合并为同名字段,也不得创建数据库、ConfigMap 或常驻控制器作为第二队列 authority。
## 4. 配置合同
HWLAB node/lane target 的 `ciResourceGovernance` 至少声明:
- `build.maxParallelServices`:单个构建 TaskRun 内最多并行服务数。
- `build.taskTimeout`:单个构建 TaskRun 的 Tekton timeout。
- `build.stepResources`:构建工具 step 的 CPU/内存 requests 与 limits。
- `build.sidecarResources`BuildKit sidecar 的 CPU/内存 requests 与 limits。
- `build.priorityClassName`:构建 Pod 的可抢占优先级;必须低于核心运行面。
- `dedupe.identityFields`:稳定意图键字段,固定为 repository、lane、source commit 和 pipeline intent。
- `coreProtection`:核心服务优先级和最小资源预算的配置引用;各服务事实仍归自身 owning YAML,不复制进 HWLAB lane YAML。
资源字段使用 Kubernetes quantity 字符串。requests 不得超过 limitsCPU 和内存必须同时声明。NC01 默认同时运行的构建服务数不得由代码、环境变量或运行面探测回填。
## 5. 原子需求
### 5.1 CI-RESOURCE-REQ-001 分层并发与排队
Renderer 必须只对 YAML 选中的构建 matrix 建立有界 worker 队列。单个构建 TaskRun 同时工作的服务数不得超过 `maxParallelServices`,每个服务使用独立工作目录和结果目录,任一批失败后不得继续下一批。原先依赖完整 matrix 的后续任务仍依赖该构建任务,非构建任务及其既有依赖语义保持不变。
### 5.2 CI-RESOURCE-REQ-002 资源合同
构建 TaskRun 的工具 step 和 BuildKit sidecar 必须带 YAML 声明的 `computeResources`。构建 Pod 使用低于核心服务的 PriorityClass;资源不足时允许构建排队或被抢占,不允许核心 API、DNS 或控制面为构建让路。
### 5.3 CI-RESOURCE-REQ-003 稳定去重
自动触发的发布意图键固定为 `repository + lane + source commit + pipeline intent`。相同键已处于 queued、running 或 succeeded 时,自动 poll 不得再创建第二次构建。人工重跑必须使用显式受控入口,携带独立、可审计的 rerun intent;不得通过删除旧 PipelineRun、修改 label 或人工创建 PipelineRun 绕过去重。
### 5.4 CI-RESOURCE-REQ-004 核心运行面保护
CoreDNS 和 k3s 控制面使用 Kubernetes 系统关键优先级。Sub2API 和集群内 Public Edge 使用平台核心优先级与自身 owning YAML 的 requests/limits。Host 运行的 Public Edge 使用其 owning YAML 声明的 CPU/内存保留或限制。任何保护变更必须通过相应受控 CLI 渲染和部署,禁止从 live workload 反解后强写。
### 5.5 CI-RESOURCE-REQ-005 状态与诊断
`plan``status``diagnosis` 必须在一次输出中披露:YAML configRef、触发层 concurrency、构建层 max parallel、批次数、运行/排队 TaskRun 数、step/sidecar 资源合同、priority class、稳定意图键状态和核心保护摘要。共享读取超时只能投影为 warning 或 unavailable,不得把全部 consumer 伪装为真实失败。
## 6. 验收
- L0:配置解析、资源 quantity、requests/limits 关系、matrix 收敛、有界 worker 队列和依赖保持通过轻量验证。
- L1:受控 `plan/status/diagnosis` 显示 YAML 来源、并发预算、批次、资源和保护状态,不依赖裸 Kubernetes 命令。
- L2:由正常 HWLAB source PR merge 自动触发,禁止人工补 PipelineRun;观察到同一时刻构建 TaskRun 不超过 YAML 预算,相同 source commit 的自动 poll 不产生第二次构建,所有构建 Pod 带资源合同,并且 NC01 Node、CoreDNS、Sub2API、Public Edge 和公开 API 在构建期间保持健康。
验收失败时保留自动链证据并修复 owning YAML 或 renderer。重启 k3s、删除 Pod/PipelineRun、手工 Argo sync、运行时 patch、mirror flush 或人工补跑均不能作为最终通过证据。
@@ -0,0 +1,135 @@
// Injected into the HWLAB control-plane renderer. It intentionally uses the
// surrounding renderer's overlay, stepEnv, patchScript, YAML, and text values.
function patchPipelineTaskRuntime(task) {
const governance = overlay.ciResourceGovernance?.build;
const governed = governance && task.name === governance.matrixTaskName;
if (governed) task.timeout = governance.taskTimeout;
for (const sidecar of task.taskSpec?.sidecars || []) {
if (overlay.buildkitSidecarImage && typeof sidecar.image === 'string' && sidecar.image.includes('buildkit')) sidecar.image = overlay.buildkitSidecarImage;
if (governed) sidecar.computeResources = structuredClone(governance.sidecarResources);
}
for (const step of task.taskSpec?.steps || []) {
if (step.image === overlay.toolsImage && overlay.toolsImagePullPolicy) step.imagePullPolicy = overlay.toolsImagePullPolicy;
if (governed) step.computeResources = structuredClone(governance.stepResources);
if (Array.isArray(step.env)) {
for (const env of step.env) {
if (Object.prototype.hasOwnProperty.call(stepEnv, env.name) && stepEnv[env.name] !== undefined) env.value = stepEnv[env.name];
}
}
step.env = Array.isArray(step.env) ? step.env : [];
const existingEnv = new Set(step.env.map((env) => env.name));
for (const [name, value] of Object.entries(stepEnv)) {
if (value !== undefined && !existingEnv.has(name)) step.env.push({ name, value });
}
if (typeof step.script === 'string') step.script = patchScript(step.script);
}
}
function applyPipelineBuildQueue(doc) {
const governance = overlay.ciResourceGovernance?.build;
if (!governance) return;
const tasks = Array.isArray(doc.spec.tasks) ? doc.spec.tasks : [];
const task = tasks.find((candidate) => candidate?.name === governance.matrixTaskName);
if (!task) return;
const matrixParams = Array.isArray(task.matrix?.params) ? task.matrix.params : [];
const serviceParam = matrixParams.find((param) => param?.name === 'service-id');
if (!serviceParam || !Array.isArray(serviceParam.value) || serviceParam.value.length === 0) {
throw new Error(`CI resource governance task ${governance.matrixTaskName} must declare a static service-id matrix`);
}
const maxParallel = governance.maxParallelServices;
const publish = (task.taskSpec?.steps || []).find((step) => step?.name === 'publish');
if (!publish || typeof publish.script !== 'string') throw new Error(`CI resource governance task ${governance.matrixTaskName} must contain a publish script`);
const serviceIds = serviceParam.value.map((value) => String(value));
let workerScript = publish.script
.replaceAll('$(params.service-id)', '$service_id')
.replaceAll('/workspace/service-work', '/workspace/service-work/$service_id')
.replaceAll('/tekton/results', '/workspace/service-results-tekton/$service_id');
publish.script = [
'set -eu',
'build_service() {',
' service_id="$1"',
' mkdir -p "/workspace/service-work/$service_id" "/workspace/service-results-tekton/$service_id"',
workerScript.split('\n').map((line) => ` ${line}`).join('\n'),
'}',
`max_parallel=${maxParallel}`,
'service_csv="$(params.services)"',
'old_ifs="$IFS"',
"IFS=','",
'set -- $service_csv',
'IFS="$old_ifs"',
'failed=0',
'while [ "$#" -gt 0 ]; do',
' pids=""',
' launched=0',
' while [ "$#" -gt 0 ] && [ "$launched" -lt "$max_parallel" ]; do',
' service_id="$1"',
' shift',
' (build_service "$service_id") &',
' pids="$pids $!"',
' launched=$((launched + 1))',
' done',
' for pid in $pids; do if ! wait "$pid"; then failed=1; fi; done',
' if [ "$failed" -ne 0 ]; then exit "$failed"; fi',
'done',
].join('\n');
task.params = (task.params || []).map((param) => param?.name === 'service-id' ? { name: 'services', value: '$(params.services)' } : param);
if (!task.params.some((param) => param?.name === 'services')) task.params.push({ name: 'services', value: '$(params.services)' });
task.taskSpec.params = (task.taskSpec.params || []).map((param) => param?.name === 'service-id' ? { ...param, name: 'services' } : param);
task.taskSpec.results = [];
delete task.matrix;
doc.metadata.annotations['unidesk.ai/ci-build-queue-mode'] = 'single-taskrun-bounded-workers';
doc.metadata.annotations['unidesk.ai/ci-build-service-count'] = String(serviceIds.length);
doc.metadata.annotations['unidesk.ai/ci-build-batch-count'] = String(Math.ceil(serviceIds.length / maxParallel));
doc.metadata.annotations['unidesk.ai/ci-build-max-parallel'] = String(maxParallel);
}
function patchManifestObject(doc) {
if (!doc || typeof doc !== 'object') return false;
if (doc.kind !== 'Pipeline' || !doc.spec) return false;
const defaults = {
'git-url': overlay.gitUrl,
'git-read-url': overlay.gitReadUrl,
'git-write-url': overlay.gitWriteUrl,
'catalog-path': overlay.catalogPath,
'runtime-path': overlay.runtimePath,
'registry-prefix': overlay.registryPrefix,
};
for (const param of doc.spec?.params || []) {
if (Object.prototype.hasOwnProperty.call(defaults, param.name)) param.default = defaults[param.name];
}
doc.metadata = doc.metadata || {};
if (typeof overlay.pipelineName === 'string' && overlay.pipelineName.length > 0) doc.metadata.name = overlay.pipelineName;
doc.metadata.annotations = doc.metadata.annotations || {};
doc.metadata.annotations['hwlab.pikastech.local/download-profile'] = overlay.downloadProfileId;
doc.metadata.annotations['hwlab.pikastech.local/network-profile'] = overlay.networkProfileId;
if (overlay.ciResourceGovernance) {
doc.metadata.annotations['unidesk.ai/ci-resource-governance-config-ref'] = overlay.ciResourceGovernance.configRef;
doc.metadata.annotations['unidesk.ai/ci-dedupe-identity'] = overlay.ciResourceGovernance.dedupe.identityFields.join('+');
}
for (const task of doc.spec?.tasks || []) patchPipelineTaskRuntime(task);
applyPipelineBuildQueue(doc);
return true;
}
function patchStructuredPipeline() {
try {
const doc = JSON.parse(text);
if (!patchManifestObject(doc)) return false;
text = JSON.stringify(doc, null, 2) + '\n';
return true;
} catch (error) {
if (String(error?.message || '').includes('CI resource governance')) throw error;
}
if (YAML) {
try {
const docs = YAML.parseAllDocuments(text).map((document) => document.toJS()).filter((doc) => doc !== null);
const changed = docs.some((doc) => patchManifestObject(doc));
if (!changed) return false;
text = docs.map((doc) => YAML.stringify(doc).trimEnd()).join('\n---\n') + '\n';
return true;
} catch (error) {
if (String(error?.message || '').includes('CI resource governance')) throw error;
}
}
return false;
}
+87
View File
@@ -396,6 +396,28 @@ export interface HwlabRuntimeBuildkitSpec {
readonly sourceImage: string;
}
export interface HwlabRuntimeComputeResourcesSpec {
readonly requests: { readonly cpu: string; readonly memory: string };
readonly limits: { readonly cpu: string; readonly memory: string };
}
export interface HwlabRuntimeCiResourceGovernanceSpec {
readonly build: {
readonly matrixTaskName: string;
readonly maxParallelServices: number;
readonly taskTimeout: string;
readonly priorityClassName: string;
readonly stepResources: HwlabRuntimeComputeResourcesSpec;
readonly sidecarResources: HwlabRuntimeComputeResourcesSpec;
};
readonly dedupe: {
readonly identityFields: readonly ["repository", "lane", "sourceCommit", "pipelineIntent"];
};
readonly coreProtection: {
readonly configRefs: readonly string[];
};
}
export interface HwlabRuntimeObservabilitySpec {
readonly prometheusOperator: boolean;
readonly traceExplorerUrlTemplate?: string;
@@ -830,6 +852,7 @@ export interface HwlabRuntimeLaneSpec {
readonly publicApiUrl: string;
readonly stepEnv: Record<string, string>;
readonly buildkit?: HwlabRuntimeBuildkitSpec;
readonly ciResourceGovernance?: HwlabRuntimeCiResourceGovernanceSpec;
readonly bootstrapAdmin?: HwlabRuntimeBootstrapAdminSpec;
readonly codeAgentProvider?: HwlabRuntimeCodeAgentProviderSpec;
readonly codeAgentRuntime?: HwlabRuntimeCodeAgentRuntimeSpec;
@@ -897,6 +920,7 @@ interface HwlabLaneConfig {
readonly public: { readonly webUrl: string; readonly apiUrl: string };
readonly stepEnv: Record<string, string>;
readonly buildkit?: HwlabRuntimeBuildkitSpec;
readonly ciResourceGovernance?: HwlabRuntimeCiResourceGovernanceSpec;
readonly bootstrapAdmin?: HwlabRuntimeBootstrapAdminSpec;
readonly codeAgentProvider?: HwlabRuntimeCodeAgentProviderSpec;
readonly codeAgentRuntime?: HwlabRuntimeCodeAgentRuntimeSpec;
@@ -1163,6 +1187,7 @@ function laneConfig(id: HwlabRuntimeLane, raw: Record<string, unknown>): HwlabLa
},
stepEnv: optionalStringRecord(raw.stepEnv, `lanes.${id}.stepEnv`),
buildkit: buildkitConfig(raw.buildkit, `lanes.${id}.buildkit`),
ciResourceGovernance: ciResourceGovernanceConfig(raw.ciResourceGovernance, `lanes.${id}.ciResourceGovernance`),
bootstrapAdmin: bootstrapAdminConfig(raw.bootstrapAdmin, `lanes.${id}.bootstrapAdmin`),
codeAgentProvider: codeAgentProviderConfig(raw.codeAgentProvider, `lanes.${id}.codeAgentProvider`),
codeAgentRuntime: codeAgentRuntimeConfig(raw.codeAgentRuntime, `lanes.${id}.codeAgentRuntime`),
@@ -1200,6 +1225,7 @@ function laneTargetConfig(
public: mergeOptionalRecord(baseRaw.public, targetRaw.public),
stepEnv: mergeOptionalRecord(baseRaw.stepEnv, targetRaw.stepEnv) ?? {},
buildkit: mergeOptionalRecord(baseRaw.buildkit, targetRaw.buildkit),
ciResourceGovernance: mergeOptionalRecord(baseRaw.ciResourceGovernance, targetRaw.ciResourceGovernance),
bootstrapAdmin: mergeOptionalRecord(baseRaw.bootstrapAdmin, targetRaw.bootstrapAdmin),
codeAgentProvider: mergeOptionalRecord(baseRaw.codeAgentProvider, targetRaw.codeAgentProvider),
codeAgentRuntime: mergeOptionalRecord(baseRaw.codeAgentRuntime, targetRaw.codeAgentRuntime),
@@ -1295,6 +1321,66 @@ function buildkitConfig(value: unknown, path: string): HwlabRuntimeBuildkitSpec
};
}
function kubernetesQuantity(value: unknown, path: string, kind: "cpu" | "memory"): string {
if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty Kubernetes quantity`);
const pattern = kind === "cpu" ? /^(?:[1-9]\d*|0)(?:\.\d+)?m?$/u : /^(?:[1-9]\d*|0)(?:\.\d+)?(?:Ki|Mi|Gi|Ti)$/u;
if (!pattern.test(value)) throw new Error(`${path} must be a supported Kubernetes ${kind} quantity`);
return value;
}
function computeResourcesConfig(value: unknown, path: string): HwlabRuntimeComputeResourcesSpec {
const raw = asRecord(value, path);
const requests = asRecord(raw.requests, `${path}.requests`);
const limits = asRecord(raw.limits, `${path}.limits`);
const result = {
requests: {
cpu: kubernetesQuantity(requests.cpu, `${path}.requests.cpu`, "cpu"),
memory: kubernetesQuantity(requests.memory, `${path}.requests.memory`, "memory"),
},
limits: {
cpu: kubernetesQuantity(limits.cpu, `${path}.limits.cpu`, "cpu"),
memory: kubernetesQuantity(limits.memory, `${path}.limits.memory`, "memory"),
},
};
if (kubernetesQuantityValue(result.requests.cpu, "cpu") > kubernetesQuantityValue(result.limits.cpu, "cpu")) throw new Error(`${path}.requests.cpu must not exceed limits.cpu`);
if (kubernetesQuantityValue(result.requests.memory, "memory") > kubernetesQuantityValue(result.limits.memory, "memory")) throw new Error(`${path}.requests.memory must not exceed limits.memory`);
return result;
}
function kubernetesQuantityValue(value: string, kind: "cpu" | "memory"): number {
if (kind === "cpu") return value.endsWith("m") ? Number(value.slice(0, -1)) / 1000 : Number(value);
const units = { Ki: 1024, Mi: 1024 ** 2, Gi: 1024 ** 3, Ti: 1024 ** 4 } as const;
const unit = value.slice(-2) as keyof typeof units;
return Number(value.slice(0, -2)) * units[unit];
}
function ciResourceGovernanceConfig(value: unknown, path: string): HwlabRuntimeCiResourceGovernanceSpec | undefined {
if (value === undefined) return undefined;
const raw = asRecord(value, path);
const build = asRecord(raw.build, `${path}.build`);
const dedupe = asRecord(raw.dedupe, `${path}.dedupe`);
const coreProtection = asRecord(raw.coreProtection, `${path}.coreProtection`);
const identityFields = stringArrayField(dedupe, "identityFields", `${path}.dedupe`);
const expectedIdentityFields = ["repository", "lane", "sourceCommit", "pipelineIntent"] as const;
if (JSON.stringify(identityFields) !== JSON.stringify(expectedIdentityFields)) {
throw new Error(`${path}.dedupe.identityFields must be repository, lane, sourceCommit, pipelineIntent in that order`);
}
const taskTimeout = stringField(build, "taskTimeout", `${path}.build`);
if (!/^(?:\d+h)?(?:\d+m)?(?:\d+s)?$/u.test(taskTimeout) || !/\d/u.test(taskTimeout)) throw new Error(`${path}.build.taskTimeout must be a Tekton duration`);
return {
build: {
matrixTaskName: stringField(build, "matrixTaskName", `${path}.build`),
maxParallelServices: boundedIntegerField(build, "maxParallelServices", `${path}.build`, 1, 32),
taskTimeout,
priorityClassName: stringField(build, "priorityClassName", `${path}.build`),
stepResources: computeResourcesConfig(build.stepResources, `${path}.build.stepResources`),
sidecarResources: computeResourcesConfig(build.sidecarResources, `${path}.build.sidecarResources`),
},
dedupe: { identityFields: expectedIdentityFields },
coreProtection: { configRefs: nonEmptyStringArrayField(coreProtection, "configRefs", `${path}.coreProtection`) },
};
}
function sourceRefField(obj: Record<string, unknown>, key: string, path: string): string {
const value = stringField(obj, key, path);
if (!/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${path}.${key} has an unsupported format`);
@@ -2819,6 +2905,7 @@ function buildRuntimeLaneSpec(config: HwlabLaneConfig): HwlabRuntimeLaneSpec {
publicApiUrl: config.public.apiUrl,
stepEnv: config.stepEnv,
...(config.buildkit === undefined ? {} : { buildkit: config.buildkit }),
...(config.ciResourceGovernance === undefined ? {} : { ciResourceGovernance: config.ciResourceGovernance }),
...(config.bootstrapAdmin === undefined ? {} : { bootstrapAdmin: config.bootstrapAdmin }),
...(config.codeAgentProvider === undefined ? {} : { codeAgentProvider: config.codeAgentProvider }),
...(config.codeAgentRuntime === undefined ? {} : { codeAgentRuntime: config.codeAgentRuntime }),
+9
View File
@@ -120,6 +120,15 @@ export function nodeRuntimeExpected(spec: HwlabRuntimeLaneSpec): Record<string,
sidecarImage: spec.buildkit.sidecarImage,
sourceImage: spec.buildkit.sourceImage,
},
ciResourceGovernance: spec.ciResourceGovernance === undefined ? null : {
configRef: `${hwlabRuntimeLaneConfigPath()}#lanes.${spec.lane}.targets.${spec.nodeId}.ciResourceGovernance`,
...spec.ciResourceGovernance,
build: {
...spec.ciResourceGovernance.build,
serviceCount: spec.serviceIds.length,
batchCount: Math.ceil(spec.serviceIds.length / spec.ciResourceGovernance.build.maxParallelServices),
},
},
dockerBuildProxy: {
http: spec.networkProfile.dockerBuildProxy.http,
https: spec.networkProfile.dockerBuildProxy.https,
+2 -58
View File
@@ -54,6 +54,7 @@ const runtimeGitopsVerifyNativeScript = readFileSync(rootPath("scripts/native/hw
const featureConfigSchemaWarningNativeScript = readFileSync(rootPath("scripts/native/cicd/feature-config-schema-warning.mjs"), "utf8").trimEnd();
const ajv2020BundleScript = readFileSync(rootPath("scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js"), "utf8").trimEnd();
const runtimePipelineProvenanceNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-pipeline-provenance.mjs"), "utf8").trimEnd();
const runtimePipelineResourceGovernanceNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-pipeline-resource-governance.mjs"), "utf8").trimEnd();
export function nodeRuntimeGitMirrorJobName(mirror: NodeRuntimeGitMirrorTargetSpec, action: "sync" | "flush"): string {
const prefix = action === "sync" ? mirror.syncJobPrefix : mirror.flushJobPrefix;
@@ -2637,64 +2638,7 @@ export function nodeRuntimePipelinePostprocessScript(
" validatePrepareSourceDependencyScript(result);",
" return result;",
"}",
"function patchManifestObject(doc) {",
" if (!doc || typeof doc !== 'object') return false;",
" if (doc.kind !== 'Pipeline' || !doc.spec) return false;",
" const defaults = {",
" 'git-url': overlay.gitUrl,",
" 'git-read-url': overlay.gitReadUrl,",
" 'git-write-url': overlay.gitWriteUrl,",
" 'catalog-path': overlay.catalogPath,",
" 'runtime-path': overlay.runtimePath,",
" 'registry-prefix': overlay.registryPrefix,",
" };",
" for (const param of doc.spec?.params || []) {",
" if (Object.prototype.hasOwnProperty.call(defaults, param.name)) param.default = defaults[param.name];",
" }",
" doc.metadata = doc.metadata || {};",
" if (typeof overlay.pipelineName === 'string' && overlay.pipelineName.length > 0) doc.metadata.name = overlay.pipelineName;",
" doc.metadata.annotations = doc.metadata.annotations || {};",
" doc.metadata.annotations['hwlab.pikastech.local/download-profile'] = overlay.downloadProfileId;",
" doc.metadata.annotations['hwlab.pikastech.local/network-profile'] = overlay.networkProfileId;",
" for (const task of doc.spec?.tasks || []) {",
" for (const sidecar of task.taskSpec?.sidecars || []) {",
" if (overlay.buildkitSidecarImage && typeof sidecar.image === 'string' && sidecar.image.includes('buildkit')) sidecar.image = overlay.buildkitSidecarImage;",
" }",
" for (const step of task.taskSpec?.steps || []) {",
" if (step.image === overlay.toolsImage && overlay.toolsImagePullPolicy) step.imagePullPolicy = overlay.toolsImagePullPolicy;",
" if (Array.isArray(step.env)) {",
" for (const env of step.env) {",
" if (Object.prototype.hasOwnProperty.call(stepEnv, env.name) && stepEnv[env.name] !== undefined) env.value = stepEnv[env.name];",
" }",
" }",
" step.env = Array.isArray(step.env) ? step.env : [];",
" const existingEnv = new Set(step.env.map((env) => env.name));",
" for (const [name, value] of Object.entries(stepEnv)) {",
" if (value !== undefined && !existingEnv.has(name)) step.env.push({ name, value });",
" }",
" if (typeof step.script === 'string') step.script = patchScript(step.script);",
" }",
" }",
" return true;",
"}",
"function patchStructuredPipeline() {",
" try {",
" const doc = JSON.parse(text);",
" if (!patchManifestObject(doc)) return false;",
" text = JSON.stringify(doc, null, 2) + '\\n';",
" return true;",
" } catch {}",
" if (YAML) {",
" try {",
" const docs = YAML.parseAllDocuments(text).map((document) => document.toJS()).filter((doc) => doc !== null);",
" const changed = docs.some((doc) => patchManifestObject(doc));",
" if (!changed) return false;",
" text = docs.map((doc) => YAML.stringify(doc).trimEnd()).join('\\n---\\n') + '\\n';",
" return true;",
" } catch {}",
" }",
" return false;",
"}",
...runtimePipelineResourceGovernanceNativeScript.split("\n"),
"function patchGitMirrorTransportYaml() {",
" const mirror = overlay.gitMirror || {};",
" const transport = mirror.githubTransport || {};",
+4
View File
@@ -117,6 +117,10 @@ export function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record<str
npmFetchTimeoutMs: spec.downloadProfile.npm.fetchTimeoutSeconds * 1000,
npmRetries: spec.downloadProfile.npm.retries,
stepEnv: spec.stepEnv,
ciResourceGovernance: spec.ciResourceGovernance === undefined ? undefined : {
configRef: `${hwlabRuntimeLaneConfigPath()}#lanes.${spec.lane}.targets.${spec.nodeId}.ciResourceGovernance`,
...spec.ciResourceGovernance,
},
observability: spec.observability,
runtimeStore: spec.runtimeStore,
codeAgentRuntime: spec.codeAgentRuntime === undefined ? undefined : {
@@ -909,9 +909,15 @@ function remotePipelineRun(binding: PacSourceArtifactBinding, pipeline: Record<s
...pipelineRunAnnotations(binding, provenance),
"pipelinesascode.tekton.dev/pipeline": pipelinePath,
};
let deterministicName: string | null = null;
if (provenance.renderer === "hwlab-runtime-lane") {
if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new Error(`HWLAB source artifact lane ${binding.consumer.lane} is not declared`);
const spec = hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node);
if (spec.ciResourceGovernance !== undefined) {
deterministicName = `${binding.consumer.pipelineRunPrefix}-{{ revision }}`;
if (deterministicName.length - "{{ revision }}".length + 40 > 63) throw new Error(`HWLAB deterministic PipelineRun name exceeds 63 characters for ${binding.consumer.id}`);
annotations["unidesk.ai/ci-dedupe-mode"] = "deterministic-kubernetes-name";
}
Object.assign(annotations, {
"hwlab.pikastech.local/ci-contract": "tekton-native-primitive-tasks",
"hwlab.pikastech.local/download-profile": spec.downloadProfileId,
@@ -928,7 +934,7 @@ function remotePipelineRun(binding: PacSourceArtifactBinding, pipeline: Record<s
apiVersion: "tekton.dev/v1",
kind: "PipelineRun",
metadata: {
generateName: `${binding.consumer.pipelineRunPrefix}-`,
...(deterministicName === null ? { generateName: `${binding.consumer.pipelineRunPrefix}-` } : { name: deterministicName }),
namespace: binding.consumer.namespace,
annotations,
labels: pipelineRunLabels(binding, "hwlab"),
@@ -1015,12 +1021,16 @@ function taskRunTemplate(binding: PacSourceArtifactBinding): Record<string, unkn
?? (Object.prototype.hasOwnProperty.call(binding.consumer.params, "service_account")
? requiredParam(binding, "service_account")
: "{{ service_account }}");
const priorityClassName = binding.consumer.sourceArtifact.renderer === "hwlab-runtime-lane" && isHwlabRuntimeLane(binding.consumer.lane)
? hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node).ciResourceGovernance?.build.priorityClassName
: undefined;
return {
serviceAccountName,
podTemplate: {
hostNetwork: declared.hostNetwork,
dnsPolicy: declared.dnsPolicy,
securityContext: { fsGroup: declared.fsGroup },
...(priorityClassName === undefined ? {} : { priorityClassName }),
...(Object.keys(nodeSelector).length === 0 ? {} : { nodeSelector }),
},
};
+116 -1
View File
@@ -15,6 +15,7 @@ import {
sha256Fingerprint,
} from "./platform-infra-ops-library";
import { materializeYamlComposition } from "./yaml-composition";
import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./hwlab-node-lanes";
import { kubernetesWatchOneEventShellFunction } from "./kubernetes-watch";
import { pacNodeReadOnlyNext, pacReadOnlyNext, resolveCicdDeliveryAuthority } from "./cicd-delivery-authority";
import { DEFAULT_READ_ONLY_LONG_POLL_TIMEOUT_MS, parseStrictDuration, readOnlyLongPollTransportTimeoutMs, runReadOnlyLongPoll } from "./read-only-long-poll";
@@ -386,7 +387,10 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
const options = parseDiagnoseRegressionOptions(args.slice(1));
const result = await diagnoseRegression(config, options);
if (options.full || options.raw || options.json) return result;
return rendered(result, "platform-infra pipelines-as-code diagnose-regression", renderPacRegressionDiagnosis(result).trimEnd().split("\n"));
return rendered(result, "platform-infra pipelines-as-code diagnose-regression", [
...renderPacRegressionDiagnosis(result).trimEnd().split("\n"),
...renderCiResourceGovernance(record(result.ciResourceGovernance)),
]);
}
if (action === "delivery-timing" || action === "timing") {
const options = parseCommonOptions(args.slice(1));
@@ -1468,6 +1472,7 @@ function plan(options: CommonOptions): Record<string, unknown> {
release: pac.release,
repository: repositorySummary(repository),
consumer: consumerObservationSummary(consumer),
ciResourceGovernance: ciResourceGovernanceSummary(consumer, repository),
secrets,
policy: policyChecks(repository),
warnings: pac.validationWarnings,
@@ -1600,6 +1605,13 @@ async function status(config: UniDeskConfig, options: CommonOptions, collectArti
const observed = await capturePacReadOnly(config, pac, "status", target, consumer, remoteScript("status", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "", [consumer], collectArtifactLogs), `${stringValue(next.status)} --full`);
const { result, parsed, observation } = observed;
const summary = parsed === null ? null : statusSummary(parsed);
const ciResourceGovernance = ciResourceGovernanceSummary(
consumer,
repository,
arrayRecords(summary?.taskRuns),
arrayRecords(summary?.pipelineRuns),
observation.ok,
);
const deliveryAuthority = resolveCicdDeliveryAuthority({ consumerId: consumer.id, node: consumer.node, lane: consumer.lane });
const deliveryBudget = observePacStatusDeliveryBudget({ policy: pac.deliveryTiming, targetId: target.id, consumerId: consumer.id, summary });
const deliveryWarning = record(deliveryBudget.warning);
@@ -1613,6 +1625,7 @@ async function status(config: UniDeskConfig, options: CommonOptions, collectArti
deliveryAuthority,
coverage: consumerCoverage(pac, target.id),
summary,
ciResourceGovernance,
deliveryBudget,
observation,
remote: parsed === null ? compactCapture(result, { full: true }) : options.raw ? parsed : undefined,
@@ -1917,6 +1930,7 @@ async function diagnoseRegression(config: UniDeskConfig, options: HistoryOptions
const pac = readPacConfig({ consumerId: options.consumerId });
const target = resolveTarget(pac, options.targetId);
const consumer = resolveConsumer(pac, options.consumerId);
const repository = resolveRepository(pac, consumer.repositoryRef);
if (consumer.node.toLowerCase() !== target.id.toLowerCase()) {
throw new Error(`Pipelines-as-Code consumer ${consumer.id} belongs to ${consumer.node}, not target ${target.id}`);
}
@@ -1955,6 +1969,7 @@ async function diagnoseRegression(config: UniDeskConfig, options: HistoryOptions
observation: historyResult.observation,
historyErrors: historyResult.historyErrors,
warnings: historyResult.warnings,
ciResourceGovernance: ciResourceGovernanceSummary(consumer, repository),
valuesPrinted: false,
};
}
@@ -2637,6 +2652,72 @@ function repositorySummary(repository: PacRepository): Record<string, unknown> {
};
}
function ciResourceGovernanceSummary(
consumer: PacConsumer,
repository: PacRepository,
taskRuns: readonly Record<string, unknown>[] = [],
pipelineRuns: readonly Record<string, unknown>[] = [],
liveObservationAvailable = false,
): Record<string, unknown> | null {
if (!isHwlabRuntimeLane(consumer.lane)) return null;
const lane = hwlabRuntimeLaneSpecForNode(consumer.lane, consumer.node);
const governance = lane.ciResourceGovernance;
if (governance === undefined) return null;
const runningTaskRuns = taskRuns.filter((item) => {
const status = stringValue(item.status).toLowerCase();
const reason = stringValue(item.reason).toLowerCase();
return status === "running" || status === "unknown" || reason === "running";
}).length;
const queuedTaskRuns = taskRuns.filter((item) => {
const status = stringValue(item.status).toLowerCase();
const reason = stringValue(item.reason).toLowerCase();
return status === "pending" || reason.includes("pending") || reason.includes("queued");
}).length;
const activeOrSucceeded = pipelineRuns.filter((item) => {
const status = stringValue(item.status).toLowerCase();
const reason = stringValue(item.reason).toLowerCase();
return ["running", "unknown", "succeeded", "true"].includes(status)
|| ["running", "succeeded", "completed"].includes(reason);
});
const identityCounts = new Map<string, number>();
for (const item of activeOrSucceeded) {
const sourceCommit = stringValue(item.sourceCommit, "");
if (sourceCommit.length === 0 || sourceCommit === "-") continue;
const key = [repository.id, consumer.lane, sourceCommit, consumer.pipeline].join("+");
identityCounts.set(key, (identityCounts.get(key) ?? 0) + 1);
}
const duplicateIntentCount = [...identityCounts.values()].filter((count) => count > 1).length;
return {
configRef: `config/hwlab-node-lanes.yaml#lanes.${consumer.lane}.targets.${consumer.node}.ciResourceGovernance`,
triggerConcurrencyLimit: repository.concurrencyLimit,
queueMode: "single-taskrun-bounded-workers",
dedupeMode: "deterministic-kubernetes-name",
automaticNameTemplate: `${consumer.pipelineRunPrefix}-{{ revision }}`,
buildTaskName: governance.build.matrixTaskName,
maxParallelServices: governance.build.maxParallelServices,
serviceCount: lane.serviceIds.length,
batchCount: Math.ceil(lane.serviceIds.length / governance.build.maxParallelServices),
taskTimeout: governance.build.taskTimeout,
taskRuns: {
status: liveObservationAvailable ? "observed" : "unavailable",
running: liveObservationAvailable ? runningTaskRuns : null,
queued: liveObservationAvailable ? queuedTaskRuns : null,
observed: liveObservationAvailable ? taskRuns.length : null,
},
stepResources: governance.build.stepResources,
sidecarResources: governance.build.sidecarResources,
priorityClassName: governance.build.priorityClassName,
intentIdentity: {
fields: governance.dedupe.identityFields,
state: !liveObservationAvailable ? "not-observed" : duplicateIntentCount === 0 ? "unique" : "duplicate-observed",
duplicateCount: liveObservationAvailable ? duplicateIntentCount : null,
blocking: false,
},
coreProtectionConfigRefs: governance.coreProtection.configRefs,
valuesPrinted: false,
};
}
function consumerObservationSummary(consumer: PacConsumer): Record<string, unknown> {
return {
id: consumer.id,
@@ -2697,6 +2778,7 @@ function compactPlanJson(result: Record<string, unknown>): Record<string, unknow
valuesPrinted: false,
},
repository: result.repository,
ciResourceGovernance: result.ciResourceGovernance,
consumer: {
id: consumer.id,
node: consumer.node,
@@ -2742,6 +2824,7 @@ function compactStatusJson(result: Record<string, unknown>): Record<string, unkn
deliveryBudget: result.deliveryBudget,
observation: result.observation,
summary: compactStatusSummary(record(result.summary)),
ciResourceGovernance: result.ciResourceGovernance,
next: result.next,
valuesPrinted: false,
};
@@ -2992,6 +3075,7 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
const secrets = arrayRecords(result.secrets);
const policy = arrayRecords(result.policy);
const warnings = arrayRecords(result.warnings);
const ciResourceGovernance = record(result.ciResourceGovernance);
const lines = [
"PLATFORM-INFRA PIPELINES-AS-CODE PLAN",
...table(["TARGET", "NAMESPACE", "RELEASE", "REPOSITORY", "CONSUMER"], [[stringValue(target.id), stringValue(repository.namespace), stringValue(record(config.release).version), stringValue(repository.name), stringValue(consumer.id)]]),
@@ -3004,6 +3088,7 @@ function renderPlan(result: Record<string, unknown>): RenderedCliResult {
["cd", "Argo", stringValue(consumer.argoApplication)],
["runtime evidence", "owning YAML", `${stringValue(deliveryObservation.runtimeEvidence)} (${stringValue(deliveryObservation.configPath)})`],
]),
...renderCiResourceGovernance(ciResourceGovernance),
"",
"SECRETS",
...table(["ID", "PRESENT", "FINGERPRINT", "VALUES"], secrets.map((item) => [stringValue(item.id), boolText(item.present), stringValue(item.fingerprint), "false"])),
@@ -3078,6 +3163,7 @@ export function renderStatus(result: Record<string, unknown>): RenderedCliResult
const deliveryPlan = record(sourceObservation.plan);
const repository = record(summary.repository);
const webhooks = arrayRecords(summary.webhooks);
const ciResourceGovernance = record(result.ciResourceGovernance);
const lines = [
"PLATFORM-INFRA PIPELINES-AS-CODE STATUS",
...renderPacConfigWarnings(warnings),
@@ -3102,6 +3188,7 @@ export function renderStatus(result: Record<string, unknown>): RenderedCliResult
` argocd-repository-secret: name=${stringValue(bootstrapArgo.secretName)} exists=${boolText(bootstrapArgo.exists)} label=${boolText(bootstrapArgo.labelReady)} keys=${boolText(bootstrapArgo.keysReady)} url=${boolText(bootstrapArgo.urlReady)} password-present=${boolText(bootstrapArgo.passwordPresent)} password-decoded=${boolText(bootstrapArgo.passwordDecoded)}`,
` consumer-bootstrap-reasons: ${Array.isArray(consumerBootstrap.reasons) ? consumerBootstrap.reasons.join(",") || "-" : "-"}`,
]),
...renderCiResourceGovernance(ciResourceGovernance),
"",
"GITEA HOOKS",
...(webhooks.length === 0 ? ["-"] : table(["HOOK", "ACTIVE", "EVENTS", "URL"], webhooks.map((item) => [stringValue(item.id), boolText(item.active), Array.isArray(item.events) ? item.events.join(",") : stringValue(item.events), short(stringValue(item.url), 56)]))),
@@ -3169,6 +3256,34 @@ export function renderStatus(result: Record<string, unknown>): RenderedCliResult
return rendered(result, "platform-infra pipelines-as-code status", lines);
}
function renderCiResourceGovernance(governance: Record<string, unknown>): string[] {
if (Object.keys(governance).length === 0) return [];
const taskRuns = record(governance.taskRuns);
const intentIdentity = record(governance.intentIdentity);
const stepResources = record(governance.stepResources);
const sidecarResources = record(governance.sidecarResources);
return [
"",
"CI RESOURCE GOVERNANCE",
` config-ref: ${stringValue(governance.configRef)}`,
...table(["TRIGGER_MAX", "QUEUE_MODE", "BUILD_TASK", "SERVICE_MAX", "BATCHES", "RUNNING", "QUEUED", "PRIORITY"], [[
stringValue(governance.triggerConcurrencyLimit),
stringValue(governance.queueMode),
stringValue(governance.buildTaskName),
stringValue(governance.maxParallelServices),
stringValue(governance.batchCount),
stringValue(taskRuns.running),
stringValue(taskRuns.queued),
stringValue(governance.priorityClassName),
]]),
` step-resources: ${compactLine(JSON.stringify(stepResources))}`,
` sidecar-resources: ${compactLine(JSON.stringify(sidecarResources))}`,
` dedupe: mode=${stringValue(governance.dedupeMode)} name=${stringValue(governance.automaticNameTemplate)}`,
` intent-identity: fields=${Array.isArray(intentIdentity.fields) ? intentIdentity.fields.join("+") : "-"} state=${stringValue(intentIdentity.state)} duplicates=${stringValue(intentIdentity.duplicateCount)} blocking=false`,
` core-protection: ${Array.isArray(governance.coreProtectionConfigRefs) ? governance.coreProtectionConfigRefs.join(",") : "-"}`,
];
}
function renderPacConfigWarnings(warnings: readonly Record<string, unknown>[]): string[] {
if (warnings.length === 0) return [];
return [
+56 -3
View File
@@ -103,6 +103,11 @@ export interface PublicEdgeTarget {
configDir: string;
statePath: string;
lockPath: string;
resourceProtection: {
cpuShares: number;
memoryReservation: string;
memoryLimit: string;
};
legacy: {
containerName: string;
composePath: string;
@@ -155,11 +160,11 @@ export async function runPlatformInfraPublicEdgeCommand(config: UniDeskConfig, a
const resolution = resolveSites(target);
if (options.action === "plan") {
const result = planResult(options.configPath, target, resolution, "plan");
return renderMachine("platform-infra public-edge plan", result, "json", result.ok === true);
return renderMachine("platform-infra public-edge plan", compactPlan(result), "json", result.ok === true);
}
if (options.action === "apply") {
const result = planResult(options.configPath, target, resolution, "dry-run");
return renderMachine("platform-infra public-edge apply", result, "json", result.ok === true);
return renderMachine("platform-infra public-edge apply", compactPlan(result), "json", result.ok === true);
}
if (options.action === "status") {
const result = await status(config, options.configPath, delivery, target, resolution);
@@ -266,6 +271,7 @@ function readTarget(root: Record<string, unknown>, requestedTargetId: string | n
const listener = record(target.listener, `targets.${targetId}.listener`);
const isolation = record(target.isolation, `targets.${targetId}.isolation`);
const runtime = record(target.runtime, `targets.${targetId}.runtime`);
const resourceProtection = record(runtime.resourceProtection, `targets.${targetId}.runtime.resourceProtection`);
const legacy = record(runtime.legacy, `targets.${targetId}.runtime.legacy`);
const sites = array(target.sites, `targets.${targetId}.sites`);
const parsed: PublicEdgeTarget = {
@@ -291,6 +297,11 @@ function readTarget(root: Record<string, unknown>, requestedTargetId: string | n
configDir: absolutePath(runtime.configDir, `targets.${targetId}.runtime.configDir`),
statePath: absolutePath(runtime.statePath, `targets.${targetId}.runtime.statePath`),
lockPath: absolutePath(runtime.lockPath, `targets.${targetId}.runtime.lockPath`),
resourceProtection: {
cpuShares: positiveInteger(resourceProtection.cpuShares, `targets.${targetId}.runtime.resourceProtection.cpuShares`),
memoryReservation: dockerMemory(resourceProtection.memoryReservation, `targets.${targetId}.runtime.resourceProtection.memoryReservation`),
memoryLimit: dockerMemory(resourceProtection.memoryLimit, `targets.${targetId}.runtime.resourceProtection.memoryLimit`),
},
legacy: {
containerName: string(legacy.containerName, `targets.${targetId}.runtime.legacy.containerName`),
composePath: absolutePath(legacy.composePath, `targets.${targetId}.runtime.legacy.composePath`),
@@ -311,6 +322,9 @@ function readTarget(root: Record<string, unknown>, requestedTargetId: string | n
};
}),
};
if (dockerMemoryBytes(parsed.runtime.resourceProtection.memoryReservation) > dockerMemoryBytes(parsed.runtime.resourceProtection.memoryLimit)) {
throw inputError(`targets.${targetId}.runtime.resourceProtection.memoryReservation 不得大于 memoryLimit`, "invalid-resource-protection", `targets.${targetId}.runtime.resourceProtection`);
}
const routeId = parsed.route.split(":", 1)[0]!;
if (parsed.isolation.excludedRoutes.includes(routeId) || parsed.isolation.excludedRoutes.includes(parsed.id)) {
throw inputError(`target route 命中 excludedRoutes${parsed.route}`, "excluded-target-route", `targets.${targetId}.route`);
@@ -797,6 +811,33 @@ function compactStatus(result: Record<string, unknown>): Record<string, unknown>
};
}
function compactPlan(result: Record<string, unknown>): Record<string, unknown> {
const target = optionalRecord(result.target) ?? {};
const rendered = optionalRecord(result.rendered) ?? {};
const sites = Array.isArray(result.sites) ? result.sites : [];
const unresolved = Array.isArray(result.unresolved) ? result.unresolved : [];
return {
ok: result.ok === true,
action: result.action,
mode: result.mode,
mutation: false,
configRef: result.configRef,
target: {
id: target.id ?? null,
route: target.route ?? null,
publicAddress: target.publicAddress ?? null,
listeners: target.listeners ?? [],
containerName: target.containerName ?? null,
resourceProtection: target.resourceProtection ?? null,
},
siteSummary: { resolved: sites.length, unresolved: unresolved.length },
rendered,
warning: unresolved.length === 0 ? null : compactStatusWarning(unresolved[0]),
complete: result.complete === true,
valuesPrinted: false,
};
}
function firstStatusFailure(runtime: Record<string, unknown>): Record<string, unknown> | null {
if (optionalRecord(runtime.capture) !== null) {
return { stage: "remoteStatus", code: "public-edge-status-capture-failed" };
@@ -850,7 +891,7 @@ export function renderPublicEdgeArtifacts(target: PublicEdgeTarget, sites: Resol
const authFingerprintBlock = authMaterialFingerprint === null
? ""
: ` environment:\n UNIDESK_PUBLIC_EDGE_AUTH_MATERIAL_FINGERPRINT: ${authMaterialFingerprint}\n`;
const compose = `services:\n caddy:\n image: ${target.runtime.image}\n container_name: ${target.runtime.containerName}\n network_mode: host\n restart: unless-stopped\n${envFileBlock}${authFingerprintBlock} dns:\n${dnsServers}\n volumes:\n - ${target.runtime.caddyfilePath}:/etc/caddy/Caddyfile:ro\n - ${target.runtime.dataDir}:/data\n - ${target.runtime.configDir}:/config\n`;
const compose = `services:\n caddy:\n image: ${target.runtime.image}\n container_name: ${target.runtime.containerName}\n network_mode: host\n restart: unless-stopped\n cpu_shares: ${target.runtime.resourceProtection.cpuShares}\n mem_reservation: ${target.runtime.resourceProtection.memoryReservation}\n mem_limit: ${target.runtime.resourceProtection.memoryLimit}\n${envFileBlock}${authFingerprintBlock} dns:\n${dnsServers}\n volumes:\n - ${target.runtime.caddyfilePath}:/etc/caddy/Caddyfile:ro\n - ${target.runtime.dataDir}:/data\n - ${target.runtime.configDir}:/config\n`;
return { caddyfile: `${global}\n${blocks}`, compose };
}
@@ -1306,6 +1347,7 @@ function targetSummary(target: PublicEdgeTarget): Record<string, unknown> {
configDir: target.runtime.configDir,
statePath: target.runtime.statePath,
lockPath: target.runtime.lockPath,
resourceProtection: target.runtime.resourceProtection,
legacyContainerName: target.runtime.legacy.containerName,
dnsServers: target.runtime.dnsServers,
};
@@ -1402,6 +1444,17 @@ function positiveInteger(value: unknown, path: string): number {
return value as number;
}
function dockerMemory(value: unknown, path: string): string {
const parsed = string(value, path);
if (!/^[1-9]\d*(?:[bkmg])$/iu.test(parsed)) throw inputError(`${path} Docker 128m`, "invalid-docker-memory", path);
return parsed;
}
function dockerMemoryBytes(value: string): number {
const units: Record<string, number> = { b: 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 };
return Number(value.slice(0, -1)) * units[value.slice(-1).toLowerCase()];
}
function port(value: unknown, path: string): number {
const result = positiveInteger(value, path);
if (result > 65_535) throw new Error(`${path} 必须小于等于 65535`);
+5 -1
View File
@@ -200,7 +200,11 @@ export function plan(options: TargetOptions): Record<string, unknown> {
? `Node-local HTTPS ${target.publicExposure.publicBaseUrl} through ${target.id} Docker Caddy to the ClusterIP Sub2API Service; no PK01 Caddy/FRP and no Kubernetes Ingress/NodePort/LoadBalancer.`
: `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and ${target.id} frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
: "ClusterIP only; no public ingress or node-level exposure.",
resourcePolicy: isHostDockerTarget(target) ? "PK01 host-Docker compose is controlled by YAML; no Kubernetes resources are rendered." : "No Kubernetes CPU/memory requests or limits, matching issue #220.",
resourcePolicy: isHostDockerTarget(target)
? "PK01 host-Docker compose is controlled by YAML; no Kubernetes resources are rendered."
: target.resourceProtection === null
? "No explicit Kubernetes resource protection is declared for this target."
: `PriorityClass ${target.resourceProtection.priorityClassName} and app/Redis requests and limits are controlled by config/platform-infra/sub2api.yaml.`,
imageVersionControl: "Sub2API image repository/tag/pullPolicy are controlled by config/platform-infra/sub2api.yaml in the UniDesk repository.",
urlAllowlistControl: "Sub2API upstream URL validation options are controlled by config/platform-infra/sub2api.yaml and rendered to SECURITY_URL_ALLOWLIST_* env vars.",
networkPolicy: isHostDockerTarget(target) ? "Not applicable for PK01 host-Docker deployment." : "NetworkPolicy/allow-all is rendered with the deployment so kube-router cannot silently default-deny Sub2API cross-pod traffic.",
+53 -1
View File
@@ -182,6 +182,7 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
const appReplicas = integerField(record, "appReplicas", path);
const redisReplicas = integerField(record, "redisReplicas", path);
const runtime = parseTargetRuntime(record.runtime, path);
const resourceProtection = parseResourceProtection(record.resourceProtection, path, runtimeMode);
const image = targetImageOverride(record, path);
const dependencyImages = targetDependencyImageOverride(record, path);
const hostDocker = parseHostDockerConfig(record.hostDocker, path, runtimeMode);
@@ -198,7 +199,7 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
if (runtimeMode === "host-docker" && accountLocalProxy?.enabled === true) throw new Error(`${configPath}.${path}.accountLocalProxy must be disabled or omitted for runtimeMode=host-docker`);
if (runtimeMode === "host-docker" && publicExposure?.enabled === true && publicExposure.mode !== "pk01-local") throw new Error(`${configPath}.${path}.publicExposure.mode must be pk01-local for runtimeMode=host-docker`);
if (runtimeMode === "k3s" && publicExposure?.enabled === true && publicExposure.mode === "pk01-local") throw new Error(`${configPath}.${path}.publicExposure.mode=pk01-local is only supported for runtimeMode=host-docker`);
return { id, route, namespace: targetNamespace, runtimeMode, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, runtime, image, dependencyImages, hostDocker, publicExposure, egressProxy, accountLocalProxy };
return { id, route, namespace: targetNamespace, runtimeMode, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, runtime, resourceProtection, image, dependencyImages, hostDocker, publicExposure, egressProxy, accountLocalProxy };
});
const ids = new Set<string>();
for (const target of targets) {
@@ -209,6 +210,57 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
return targets;
}
function resourceQuantity(value: unknown, path: string, kind: "cpu" | "memory"): string {
if (typeof value !== "string" || value.length === 0) throw new Error(`${configPath}.${path} must be a non-empty Kubernetes quantity`);
const pattern = kind === "cpu" ? /^(?:[1-9]\d*|0)(?:\.\d+)?m?$/u : /^(?:[1-9]\d*|0)(?:\.\d+)?(?:Ki|Mi|Gi|Ti)$/u;
if (!pattern.test(value)) throw new Error(`${configPath}.${path} has an unsupported Kubernetes ${kind} quantity`);
return value;
}
function containerResources(value: unknown, path: string): NonNullable<Sub2ApiTargetConfig["resourceProtection"]>["app"] {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configPath}.${path} must be an object`);
const raw = value as Record<string, unknown>;
const requests = objectField(raw, "requests", path);
const limits = objectField(raw, "limits", path);
const result = {
requests: { cpu: resourceQuantity(requests.cpu, `${path}.requests.cpu`, "cpu"), memory: resourceQuantity(requests.memory, `${path}.requests.memory`, "memory") },
limits: { cpu: resourceQuantity(limits.cpu, `${path}.limits.cpu`, "cpu"), memory: resourceQuantity(limits.memory, `${path}.limits.memory`, "memory") },
};
if (resourceQuantityValue(result.requests.cpu, "cpu") > resourceQuantityValue(result.limits.cpu, "cpu")) throw new Error(`${configPath}.${path}.requests.cpu must not exceed limits.cpu`);
if (resourceQuantityValue(result.requests.memory, "memory") > resourceQuantityValue(result.limits.memory, "memory")) throw new Error(`${configPath}.${path}.requests.memory must not exceed limits.memory`);
return result;
}
function resourceQuantityValue(value: string, kind: "cpu" | "memory"): number {
if (kind === "cpu") return value.endsWith("m") ? Number(value.slice(0, -1)) / 1000 : Number(value);
const units = { Ki: 1024, Mi: 1024 ** 2, Gi: 1024 ** 3, Ti: 1024 ** 4 } as const;
const unit = value.slice(-2) as keyof typeof units;
return Number(value.slice(0, -2)) * units[unit];
}
function parseResourceProtection(value: unknown, path: string, runtimeMode: Sub2ApiTargetConfig["runtimeMode"]): Sub2ApiTargetConfig["resourceProtection"] {
if (value === undefined || value === null) return null;
if (runtimeMode !== "k3s") throw new Error(`${configPath}.${path}.resourceProtection is only supported for runtimeMode=k3s`);
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.resourceProtection must be an object`);
const raw = value as Record<string, unknown>;
const priorityClassName = stringField(raw, "priorityClassName", `${path}.resourceProtection`);
const priorityValue = integerField(raw, "priorityValue", `${path}.resourceProtection`);
const batchPriorityClassName = stringField(raw, "batchPriorityClassName", `${path}.resourceProtection`);
const batchPriorityValue = integerField(raw, "batchPriorityValue", `${path}.resourceProtection`);
if (!isKubernetesName(priorityClassName)) throw new Error(`${configPath}.${path}.resourceProtection.priorityClassName must be a Kubernetes name`);
if (!isKubernetesName(batchPriorityClassName)) throw new Error(`${configPath}.${path}.resourceProtection.batchPriorityClassName must be a Kubernetes name`);
if (priorityValue < 1 || priorityValue >= 1000000000) throw new Error(`${configPath}.${path}.resourceProtection.priorityValue must be between 1 and 999999999`);
if (batchPriorityValue >= 0 || batchPriorityValue <= -1000000000) throw new Error(`${configPath}.${path}.resourceProtection.batchPriorityValue must be between -999999999 and -1`);
return {
priorityClassName,
priorityValue,
batchPriorityClassName,
batchPriorityValue,
app: containerResources(raw.app, `${path}.resourceProtection.app`),
redis: containerResources(raw.redis, `${path}.resourceProtection.redis`),
};
}
export function parseTargetRuntime(value: unknown, path: string): Sub2ApiTargetConfig["runtime"] {
if (value === undefined || value === null) return { autoSetup: true };
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.runtime must be an object`);
+13
View File
@@ -127,6 +127,14 @@ export interface Sub2ApiTargetConfig {
runtime: {
autoSetup: boolean;
};
resourceProtection: {
priorityClassName: string;
priorityValue: number;
batchPriorityClassName: string;
batchPriorityValue: number;
app: Sub2ApiContainerResources;
redis: Sub2ApiContainerResources;
} | null;
image: Partial<Sub2ApiConfig["image"]>;
dependencyImages: Partial<Sub2ApiConfig["dependencyImages"]>;
hostDocker: Sub2ApiHostDockerConfig | null;
@@ -135,6 +143,11 @@ export interface Sub2ApiTargetConfig {
accountLocalProxy: Sub2ApiAccountLocalProxyConfig | null;
}
export interface Sub2ApiContainerResources {
requests: { cpu: string; memory: string };
limits: { cpu: string; memory: string };
}
export interface Sub2ApiHostDockerConfig {
projectName: string;
workDir: string;
+39 -5
View File
@@ -304,7 +304,36 @@ export function externalPendingManifest(sub2api: Sub2ApiConfig, target: Sub2ApiT
const runtimeConfigInitContainer = renderRuntimeConfigInitContainer(target, database, dependencyImages);
const accountLocalProxyContainer = renderAccountLocalProxyContainer(target.accountLocalProxy);
const accountLocalProxyVolume = renderAccountLocalProxyVolume(target.accountLocalProxy);
return `apiVersion: v1
const protection = target.resourceProtection;
const priorityClass = protection === null ? "" : `apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: ${protection.priorityClassName}
labels:
app.kubernetes.io/managed-by: unidesk
app.kubernetes.io/part-of: platform-infra
value: ${protection.priorityValue}
globalDefault: false
preemptionPolicy: PreemptLowerPriority
description: UniDesk platform core workloads protected from batch CI pressure.
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: ${protection.batchPriorityClassName}
labels:
app.kubernetes.io/managed-by: unidesk
app.kubernetes.io/part-of: platform-infra
value: ${protection.batchPriorityValue}
globalDefault: false
preemptionPolicy: Never
description: UniDesk batch CI yields resources to platform core workloads.
---
`;
const priorityClassName = protection === null ? "" : ` priorityClassName: ${protection.priorityClassName}\n`;
const redisResources = protection === null ? "" : renderContainerResources(protection.redis, 10);
const appResources = protection === null ? "" : renderContainerResources(protection.app, 10);
return `${priorityClass}apiVersion: v1
kind: Namespace
metadata:
name: ${target.namespace}
@@ -462,13 +491,13 @@ spec:
app.kubernetes.io/component: redis
app.kubernetes.io/part-of: platform-infra
spec:
securityContext:
${priorityClassName} securityContext:
fsGroup: 999
containers:
- name: redis
image: ${dependencyImages.redis}
imagePullPolicy: IfNotPresent
command:
${redisResources} command:
- sh
- -c
args:
@@ -535,7 +564,7 @@ spec:
app.kubernetes.io/component: app
app.kubernetes.io/part-of: platform-infra
spec:
securityContext:
${priorityClassName} securityContext:
fsGroup: 1000
initContainers:
${runtimeConfigInitContainer}
@@ -557,7 +586,7 @@ ${runtimeConfigInitContainer}
- name: sub2api
image: ${imageRef(sub2api, target)}
imagePullPolicy: ${image.pullPolicy}
ports:
${appResources} ports:
- name: http
containerPort: 8080
envFrom:
@@ -620,6 +649,11 @@ ${egressProxy}
`;
}
function renderContainerResources(resources: { requests: { cpu: string; memory: string }; limits: { cpu: string; memory: string } }, spaces: number): string {
const indent = " ".repeat(spaces);
return `${indent}resources:\n${indent} requests:\n${indent} cpu: ${resources.requests.cpu}\n${indent} memory: ${resources.requests.memory}\n${indent} limits:\n${indent} cpu: ${resources.limits.cpu}\n${indent} memory: ${resources.limits.memory}\n`;
}
export function sub2ApiProxyEnv(target: Sub2ApiTargetConfig): { httpProxy: string; noProxy: string } {
const proxy = target.egressProxy;
if (proxy === null || !proxy.enabled || !proxy.applyToSub2Api) return { httpProxy: "", noProxy: "" };
+5 -3
View File
@@ -118,9 +118,11 @@ export function policyChecks(sub2api: Sub2ApiConfig, yaml: string, target: Sub2A
detail: "Pods must not expose host ports.",
},
{
name: "no-cpu-memory-resources",
ok: !/^\s*(cpu|memory):\s*/mu.test(yaml),
detail: "Issue #220 requires no Kubernetes CPU/memory requests or limits.",
name: "core-resource-protection",
ok: target.resourceProtection === null || (/^\s*kind:\s*PriorityClass\s*$/mu.test(yaml) && /^\s*priorityClassName:\s*/mu.test(yaml) && /^\s*resources:\s*$/mu.test(yaml)),
detail: target.resourceProtection === null
? "This target does not declare a core resource protection contract."
: `YAML renders PriorityClass ${target.resourceProtection.priorityClassName} and explicit app/Redis requests and limits.`,
},
{
name: "no-resource-quota-or-limit-range",
+20 -8
View File
@@ -249,7 +249,7 @@ def is_allow_all_network_policy(item):
and spec.get("egress") == [{}]
)
def resource_findings(kind, collection):
def boundary_violations(kind, collection):
findings = []
for item in collection:
spec = item.get("spec") or {}
@@ -258,11 +258,6 @@ def resource_findings(kind, collection):
findings.append({"kind": kind, "name": item["metadata"]["name"], "field": "hostNetwork"})
all_containers = [(container, "containers") for container in template_spec.get("containers", [])] + [(container, "initContainers") for container in template_spec.get("initContainers", [])]
for container, container_group in all_containers:
resources = container.get("resources") or {}
if resources.get("requests"):
findings.append({"kind": kind, "name": item["metadata"]["name"], "container": container.get("name"), "containerGroup": container_group, "field": "resources.requests"})
if resources.get("limits"):
findings.append({"kind": kind, "name": item["metadata"]["name"], "container": container.get("name"), "containerGroup": container_group, "field": "resources.limits"})
for port in container.get("ports", []):
if "hostPort" in port:
findings.append({"kind": kind, "name": item["metadata"]["name"], "container": container.get("name"), "containerGroup": container_group, "field": "hostPort", "value": port.get("hostPort")})
@@ -297,7 +292,23 @@ for svc in services:
for port in spec.get("ports", []):
if "nodePort" in port:
service_violations.append({"name": svc["metadata"]["name"], "nodePort": port.get("nodePort")})
resource_violations = resource_findings("Deployment", deployments) + resource_findings("StatefulSet", statefulsets)
resource_violations = boundary_violations("Deployment", deployments) + boundary_violations("StatefulSet", statefulsets)
expected_resource_protection = json.loads(${JSON.stringify(JSON.stringify(target.resourceProtection))})
def workload_resource_contract(name, container_name):
workload = next((item for item in deployments if item.get("metadata", {}).get("name") == name), None)
pod_spec = (((workload or {}).get("spec") or {}).get("template") or {}).get("spec") or {}
container = next((item for item in pod_spec.get("containers", []) if item.get("name") == container_name), None)
return {"priorityClassName": pod_spec.get("priorityClassName"), "resources": (container or {}).get("resources") or {}}
resource_contracts = {
"app": workload_resource_contract("${serviceName}", "${serviceName}"),
"redis": workload_resource_contract("${redisService}", "redis"),
}
resource_protection_aligned = expected_resource_protection is None or (
resource_contracts["app"]["priorityClassName"] == expected_resource_protection["priorityClassName"]
and resource_contracts["redis"]["priorityClassName"] == expected_resource_protection["priorityClassName"]
and resource_contracts["app"]["resources"] == {"requests": expected_resource_protection["app"]["requests"], "limits": expected_resource_protection["app"]["limits"]}
and resource_contracts["redis"]["resources"] == {"requests": expected_resource_protection["redis"]["requests"], "limits": expected_resource_protection["redis"]["limits"]}
)
expected_image = "${expectedImage}"
expected_url_allowlist = json.loads(${JSON.stringify(JSON.stringify(expectedUrlAllowlist))})
sub2api_deployment = next((deployment_summary(item) for item in deployments if item["metadata"]["name"] == "${serviceName}"), None)
@@ -412,7 +423,7 @@ else:
state_model_ok = local_postgres_present and sub2api_desired_aligned and redis_desired_aligned
status_label = "pending-external-db" if external_pending else "external-db-active" if external_active else "active"
payload = {
"ok": rc("ns") == 0 and workload_ready and image_aligned and url_allowlist_aligned and proxy_env_aligned and network_policy["ok"] and boundary["internalOnly"] and len(resource_violations) == 0 and boundary["resourceQuotaCount"] == 0 and boundary["limitRangeCount"] == 0 and secret_ok and state_model_ok and rc("local-https") == 0,
"ok": rc("ns") == 0 and workload_ready and image_aligned and url_allowlist_aligned and proxy_env_aligned and network_policy["ok"] and boundary["internalOnly"] and len(resource_violations) == 0 and resource_protection_aligned and boundary["resourceQuotaCount"] == 0 and boundary["limitRangeCount"] == 0 and secret_ok and state_model_ok and rc("local-https") == 0,
"target": "${target.id}",
"route": "${target.route}",
"namespace": "${target.namespace}",
@@ -428,6 +439,7 @@ payload = {
"services": [service_summary(item) for item in services],
"pvcs": [pvc_summary(item) for item in pvcs],
"networkPolicy": network_policy,
"resourceProtection": {"expected": expected_resource_protection, "observed": resource_contracts, "aligned": resource_protection_aligned},
"localHttpsExposure": {
"enabled": ${localHttps === null ? "False" : "True"},
"action": text("local-https-action.txt"),