Merge pull request #2718 from pikasTech/feat/hwpod-host-postgres
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

feat: 声明 HWPOD host PostgreSQL 持久层
This commit is contained in:
Lyon
2026-07-21 09:04:16 +08:00
committed by GitHub
9 changed files with 171 additions and 16 deletions
+5 -1
View File
@@ -217,7 +217,11 @@ lanes:
sourceRef: hwlab/nc01-v03-admin.env
sourceKey: HWLAB_API_KEY
specRegistry:
runtimeDir: .state/hwpod-native/specs/runtime
database:
sourceRef: hwlab/nc01-v03-cloud-api-db.env
sourceKey: DATABASE_URL
schema: hwpod
table: runtime_specs
builtInConfigRefs:
- config/hwlab-hwpod-preinstalls/constart-71freq-c.yaml#hwpodPreinstall.specDocument
publicExposure:
+48
View File
@@ -166,6 +166,11 @@ postgres:
user: tasktree_production
address: 10.42.0.0/16
method: scram-sha-256
- type: hostssl
database: hwlab_nc01_v03
user: hwlab_nc01_v03_app
address: 10.42.0.0/16
method: scram-sha-256
- type: hostssl
database: hwlab_nc01_production
user: hwlab_nc01_production_app
@@ -318,6 +323,20 @@ secrets:
TASKTREE_PRODUCTION_DB_NAME: tasktree_production
randomHex:
TASKTREE_PRODUCTION_DB_PASSWORD: 32
- name: hwlab-nc01-v03-db-credentials
sourceRef: platform-db/hwlab-nc01-v03-db.env
type: env
requiredKeys:
- HWLAB_NC01_V03_DB_USER
- HWLAB_NC01_V03_DB_PASSWORD
- HWLAB_NC01_V03_DB_NAME
createIfMissing:
enabled: true
values:
HWLAB_NC01_V03_DB_USER: hwlab_nc01_v03_app
HWLAB_NC01_V03_DB_NAME: hwlab_nc01_v03
randomHex:
HWLAB_NC01_V03_DB_PASSWORD: 32
- name: hwlab-nc01-production-db-credentials
sourceRef: platform-db/hwlab-nc01-production-db.env
type: env
@@ -425,6 +444,15 @@ objects:
createdb: false
createrole: false
superuser: false
- name: hwlab_nc01_v03_app
passwordRef:
sourceRef: platform-db/hwlab-nc01-v03-db.env
key: HWLAB_NC01_V03_DB_PASSWORD
login: true
attributes:
createdb: false
createrole: false
superuser: false
- name: hwlab_nc01_production_app
passwordRef:
sourceRef: platform-db/hwlab-nc01-production-db.env
@@ -490,6 +518,11 @@ objects:
encoding: UTF8
locale: C.UTF-8
extensions: []
- name: hwlab_nc01_v03
owner: hwlab_nc01_v03_app
encoding: UTF8
locale: C.UTF-8
extensions: []
- name: hwlab_nc01_production
owner: hwlab_nc01_production_app
encoding: UTF8
@@ -640,6 +673,21 @@ exports:
- scope: hwlab-production
secret: hwlab-tasktree-db
key: DATABASE_URL
- name: hwlab-nc01-v03-cloud-api-database-url
sourceSecretRef: platform-db/hwlab-nc01-v03-db.env
render:
envKey: DATABASE_URL
format: postgresql://$(HWLAB_NC01_V03_DB_USER):$(HWLAB_NC01_V03_DB_PASSWORD)@$(PGHOST):5432/$(HWLAB_NC01_V03_DB_NAME)?sslmode=require
variables:
PGHOST: 10.42.0.1
writeToSecretSource:
sourceRef: hwlab/nc01-v03-cloud-api-db.env
key: DATABASE_URL
mode: update-or-insert
consumers:
- scope: hwlab-v03
secret: hwlab-cloud-api-v03-db
key: DATABASE_URL
- name: hwlab-nc01-production-cloud-api-database-url
sourceSecretRef: platform-db/hwlab-nc01-production-db.env
render:
+9
View File
@@ -585,6 +585,15 @@ kubernetesSecrets:
- sourceRef: hwlab/web-probe-monitor-db.env
sourceKey: DATABASE_URL
targetKey: DATABASE_URL
- name: hwlab-nc01-v03-cloud-api-db
targetId: hwlab-nc01-v03
secretName: hwlab-cloud-api-v03-db
type: Opaque
namespace: hwlab-v03
data:
- sourceRef: hwlab/nc01-v03-cloud-api-db.env
sourceKey: DATABASE_URL
targetKey: database-url
- name: hwlab-tasktree-db
targetId: hwlab-nc01-v03
secretName: hwlab-tasktree-db
@@ -98,15 +98,23 @@ HWPOD工具应提供 spec 新建、读取、列表、修改、删除、validate
Spec 工具必须以 HWPOD 标准为准输出校验结果。服务端 registry 可以提供 authority 摘要,但工具不得用服务端缺省值静默补齐未声明的危险写操作能力。
Spec CRUD 必须在 L0 与 L1 使用同一 repository 合同:
Spec CRUD 必须在 L0-L3 使用同一 PostgreSQL repository 合同:
- L0 由 `hwpod spec list|get|create|update|delete --local` 直接调用 native function
- L1 由同一命令加 `--over-api` 调用 owning YAML 固定端口上的 HWPOD API
- L0 由 `hwpod spec list|get|create|update|delete --local` 直接调用 native function
并从 owning YAML 的 Secret sourceRef 连接 development host PostgreSQL
- L1 由同一命令加 `--over-api` 调用 owning YAML 固定端口上的 HWPOD API
API 与 L0 连接同一个 development database
- L2 的 development API 与 L0/L1 使用同一个 development database
L3 使用 production owning YAML 声明的 production database
- database、role、endpoint、Secret sourceRef、连接 key 和 schema/table identity
必须来自 owning YAML,不得由命令行参数、进程目录或代码默认值补齐;
- create 和 update 接收完整 spec document,并在写入前执行与 validate 相同的校验;
- list 默认返回有界摘要,get 返回单个完整 document
- create、update 和 delete 必须输出 authority、mutable、frozen、hwpodId 和 mutation
- YAML-first 内置 spec 的 update 和 delete 必须返回 `hwpod_spec_frozen`,不得复制为 runtime spec、写入覆盖层或修改 owning YAML
- runtime spec 与内置 spec 同名时,create 必须返回 `hwpod_spec_frozen`,不得形成遮蔽或优先级覆盖。
- filesystem JSON registry、`--runtime-spec-dir` 和 runtime directory env 属于
`legacy-retire`,不得作为 fallback、双写、迁移后 overlay 或第二 authority 保留。
### 6.2 HWPOD-TOOL-REQ-002 执行动作入口
@@ -132,17 +132,35 @@ Registry 必须以 HWPOD 标准解释资源事实。缺失 HWPOD spec 的真实
Registry 必须区分两种 authority
- `yaml-first-builtin` 来自 owning YAML 解析后注入的内置 spec,`mutable=false``frozen=true`
- `runtime` 来自 HWPOD 服务状态目录,`mutable=true``frozen=false`,重启后继续可读。
- `runtime` 来自 owning YAML 声明的 host PostgreSQL`mutable=true`
`frozen=false`API、Worker、Pod 和 host 进程重启后继续可读。
Runtime repository 必须使用唯一 PostgreSQL authority
- development 的 L0、L1 和 L2 共用同一个 host PostgreSQL database
- production 的 L3 使用 production owning YAML 声明的 host PostgreSQL database
- database、role、endpoint、TLS、Secret sourceRef、sourceKey 和 schema/table identity
只能来自 owning YAML
- runtime 表以 `hwpod_id` 为唯一键,保存规范化后的完整 `document JSONB`
固定 authority `runtime``created_at``updated_at`
- repository 初始化只执行幂等 schema/table/index migration,不创建隐藏 database、
role、凭据或 filesystem fallback
- YAML-first 内置 spec 只在读取时与 PostgreSQL runtime 记录合并,
不写入 runtime 表。
Runtime CRUD 只能修改 `runtime` authority
- create 在 hwpodId 不存在时原子写入 runtime state
- create 在 hwpodId 不存在时以唯一键约束和单条事务原子写入;
- get 和 list 合并返回内置与 runtime spec,并保留 authority、mutable 和 frozen
- update 只能完整替换已有 runtime spec
- delete 只能删除已有 runtime spec
- update 只能在单条事务中完整替换已有 runtime spec,并保留 `created_at`
- delete 只能在单条事务中删除已有 runtime spec
- 内置 spec 的同名 create、update 和 delete 均返回 `hwpod_spec_frozen`
- runtime spec 不得遮蔽、覆盖、复制或回写 YAML-first 内置 spec。
filesystem JSON registry、runtime spec directory 参数和目录环境变量属于
`legacy-retire`。实现不得保留 filesystem fallback、双写、启动导入、overlay
或第二 authority;历史 JSON 只允许通过显式一次性迁移工具处理,不参与正常 CRUD。
### 6.2 HWPOD-SVC-REQ-002 租约与占用
| 编号 | 短名 | 主责模块 | 关联模块 |
@@ -240,8 +258,17 @@ flowchart LR
### 7.4 验收契约
- L0spec、topology、workflow contract 和 typed error 测试通过;runtime CRUD 只修改 disposable state,内置 spec mutation 返回 `hwpod_spec_frozen`
- L1:通过 native API 完成 runtime spec create、list、get、update、delete,并证明 API 重启后 create/update 结果仍可读取。
- L0spec、topology、workflow contract 和 typed error 测试通过;runtime CRUD
直接修改 owning YAML 选择的 development host PostgreSQL,内置 spec mutation
返回 `hwpod_spec_frozen`
- L0/L1:对同一 runtime spec 做 `--local``--over-api` 对比复测,
两条路径返回一致的 document、authority 和 fingerprint。
- L1:通过 native API 完成 runtime spec create、list、get、update、delete
并证明 API 重启后 create/update 结果仍可读取。
- L2development API 完成同一 CRUD,并证明 Deployment/Pod 重启后记录仍可读取;
该验证不得使用 Pod filesystem 作为持久化证据。
- L3:完成 L2 回归并取得本次生产操作明确授权后,production API 完成有界 CRUD
和 Pod 重启持久化验证;测试对象必须清理,内置对象指纹保持不变。
- L1:对 YAML-first 内置 spec 的同名 create、update 和 delete 均返回 `hwpod_spec_frozen`,内置 document 指纹保持不变。
- L1API、worker、Web 可分别重启;worker 重启后未完成 workflow 可继续;Web 通过固定入口完成首屏、资源列表和 operation 状态交互。
- L1D601 节点注册后,HWPOD topology 同时显示 node online、spec available 和 capability match;节点断开后显示 node offline,不得伪造硬件成功。
@@ -154,7 +154,10 @@ flowchart TD
Tool --> Evidence[operator validation summary]
```
运行发现要求 `constart-71freq-c`runtime authority 来自挂载目录和 discovery 结果。cloud-api 代码不得把 71-FREQ 路径、COM 口、probe UID、board-comm IP、HWPOD id 或旧 node id 写成隐藏默认值。
运行发现要求 `constart-71freq-c``yaml-first-builtin` authority 来自挂载目录和
discovery 结果。该内置对象不写入 runtime PostgreSQL 表。cloud-api 代码不得把
71-FREQ 路径、COM 口、probe UID、board-comm IP、HWPOD id 或旧 node id 写成
隐藏默认值。
### 5.4 Windows outbound HWPOD node/gateway 时序图
@@ -229,6 +232,11 @@ YAML-first 预装 spec 在所有运行等级都必须标记为 `yaml-first-built
回写该对象;同名 create、update 和 delete 必须返回 `hwpod_spec_frozen`,并保留
owning configRef 与变更前 document 指纹。
L0-L3 的 runtime spec 必须持久化到对应 owning YAML 声明的 host PostgreSQL。
L0/L1/L2 共用 development databaseL3 使用 production databaseAPI、Worker、
Pod 或 host 进程重启不得丢失 runtime spec。挂载目录只承载只读 YAML-first 内置
spec,不得承载 runtime JSON、fallback、双写或覆盖层。
### 6.3 HWPOD-PRE-REQ-003 D601 Windows v0.3+ 出站托管
| 编号 | 短名 | 主责模块 | 关联模块 |
+7 -2
View File
@@ -56,10 +56,15 @@ test("L1 Workbench exposure uses YAML HTTPS origin and separate bind/probe addre
expect(hwlabNativeDevelopmentHelp()).toMatchObject({ ok: true, command: "hwlab nodes native-development workbench" });
});
test("L1 HWPOD spec registry resolves runtime state and frozen built-ins from owning YAML", () => {
test("L1 HWPOD spec registry resolves PostgreSQL authority and frozen built-ins from owning YAML", () => {
const native = hwlabRuntimeLaneSpecForNode("v03", "NC01").nativeDevelopment?.hwpod;
expect(native).toBeDefined();
expect(native!.specRegistry.runtimeDir).toBe(".state/hwpod-native/specs/runtime");
expect(native!.specRegistry.database).toEqual({
sourceRef: "hwlab/nc01-v03-cloud-api-db.env",
sourceKey: "DATABASE_URL",
schema: "hwpod",
table: "runtime_specs",
});
expect(native!.specRegistry.builtInConfigRefs).toHaveLength(1);
const resolved = resolveConfigRef(native!.specRegistry.builtInConfigRefs[0], "HWPOD built-in spec");
expect(resolved.value).toMatchObject({ kind: "Hwpod", metadata: { name: "constart-71freq-c" } });
+27 -1
View File
@@ -59,6 +59,25 @@ export function runHwlabNativeDevelopmentCommand(args: string[]): Record<string,
...(result.exitCode === 0 ? {} : { error: { code: "hwlab_native_cli_failed", exitCode: result.exitCode, stderr: result.stderr.trim().slice(-2000) } }),
};
}
if (application === "hwpod" && serviceRaw === "cli") {
const commandArgs = withoutOptions(args.slice(2), ["--node", "--lane"]);
if (commandArgs.length === 0) throw new Error("native-development hwpod cli requires a HWPOD command");
const command = ["bun", "tools/hwlab-cli/bin/hwlab-cli.ts", "hwpod", ...commandArgs];
const result = runCommand(command, spec.workspace, {
timeoutMs: cliTimeoutMs(commandArgs),
env: { ...process.env, ...env, HWPOD_API_URL: env.HWPOD_NATIVE_API_URL },
});
return {
ok: result.exitCode === 0,
command: "hwlab nodes native-development hwpod cli",
node,
lane,
transport: commandArgs.includes("--over-api") ? "native-api" : "local-function",
configSource: `${hwlabRuntimeLaneConfigPath()}#lanes.${lane}.targets.${node}.nativeDevelopment.hwpod`,
hwpodResult: parsePayload(result.stdout),
...(result.exitCode === 0 ? {} : { error: { code: "hwlab_native_cli_failed", exitCode: result.exitCode, stderr: result.stderr.trim().slice(-2000) } }),
};
}
if (application === "workbench" && serviceRaw === "status") {
const remaining = withoutOptions(args.slice(2), ["--node", "--lane"]);
if (remaining.length > 0) throw new Error(`native-development workbench status does not accept positional arguments: ${remaining.join(" ")}`);
@@ -268,6 +287,11 @@ function hwlabHwpodNativeDevelopmentEnvironment(native: NonNullable<HwlabRuntime
sourceRef: native.runtimeApiAuth.sourceRef,
});
const runtimeApiKey = requiredEnvValue(authorizationSource.values, native.runtimeApiAuth.sourceKey, native.runtimeApiAuth.sourceRef);
const databaseSource = readEnvSourceFile({
root: "/root/.unidesk/.state/secrets",
sourceRef: native.specRegistry.database.sourceRef,
});
const databaseUrl = requiredEnvValue(databaseSource.values, native.specRegistry.database.sourceKey, native.specRegistry.database.sourceRef);
const builtInSpecs = native.specRegistry.builtInConfigRefs.map((configRef) => {
const resolved = resolveConfigRef(configRef, "nativeDevelopment.hwpod.specRegistry.builtInConfigRefs");
return { configRef, sha256: resolved.sha256, document: resolved.value };
@@ -284,7 +308,9 @@ function hwlabHwpodNativeDevelopmentEnvironment(native: NonNullable<HwlabRuntime
HWPOD_ACTIVITY_RETRY_MAXIMUM_ATTEMPTS: String(native.temporal.activity.retryMaximumAttempts),
HWPOD_RUNTIME_API_URL: native.runtimeApiUrl,
HWPOD_RUNTIME_API_AUTHORIZATION: `Bearer ${runtimeApiKey}`,
HWPOD_RUNTIME_SPEC_REGISTRY_DIR: native.specRegistry.runtimeDir,
HWPOD_SPEC_DATABASE_URL: databaseUrl,
HWPOD_SPEC_DATABASE_SCHEMA: native.specRegistry.database.schema,
HWPOD_SPEC_DATABASE_TABLE: native.specRegistry.database.table,
HWPOD_BUILTIN_SPEC_DOCUMENTS_JSON: JSON.stringify(builtInSpecs),
HWPOD_API_BIND_HOST: native.api.bindHost,
HWPOD_API_PROBE_HOST: native.api.probeHost,
+22 -2
View File
@@ -741,7 +741,15 @@ export interface HwlabRuntimeNativeDevelopmentSpec {
readonly stateDir: string;
readonly runtimeApiUrl: string;
readonly runtimeApiAuth: { readonly sourceRef: string; readonly sourceKey: string };
readonly specRegistry: { readonly runtimeDir: string; readonly builtInConfigRefs: readonly string[] };
readonly specRegistry: {
readonly database: {
readonly sourceRef: string;
readonly sourceKey: string;
readonly schema: string;
readonly table: string;
};
readonly builtInConfigRefs: readonly string[];
};
readonly temporal: {
readonly address: string;
readonly namespace: string;
@@ -925,6 +933,12 @@ function stringField(obj: Record<string, unknown>, key: string, path: string): s
return value;
}
function postgresIdentifierField(obj: Record<string, unknown>, key: string, path: string): string {
const value = stringField(obj, key, path);
if (!/^[a-z_][a-z0-9_]*$/u.test(value)) throw new Error(`${path}.${key} must be a lowercase PostgreSQL identifier`);
return value;
}
function enumStringField<T extends string>(obj: Record<string, unknown>, key: string, path: string, allowed: readonly T[]): T {
const value = stringField(obj, key, path);
if (!allowed.includes(value as T)) throw new Error(`${path}.${key} must be one of ${allowed.join(", ")}`);
@@ -1624,6 +1638,7 @@ function nativeHwpodConfig(value: unknown, path: string): NonNullable<HwlabRunti
const raw = asRecord(value, path);
const runtimeApiAuth = asRecord(raw.runtimeApiAuth, `${path}.runtimeApiAuth`);
const specRegistry = asRecord(raw.specRegistry, `${path}.specRegistry`);
const database = asRecord(specRegistry.database, `${path}.specRegistry.database`);
const temporal = asRecord(raw.temporal, `${path}.temporal`);
const activity = asRecord(temporal.activity, `${path}.temporal.activity`);
const api = asRecord(raw.api, `${path}.api`);
@@ -1638,7 +1653,12 @@ function nativeHwpodConfig(value: unknown, path: string): NonNullable<HwlabRunti
sourceKey: stringField(runtimeApiAuth, "sourceKey", `${path}.runtimeApiAuth`),
},
specRegistry: {
runtimeDir: relativeWorkspacePathField(stringField(specRegistry, "runtimeDir", `${path}.specRegistry`), `${path}.specRegistry.runtimeDir`),
database: {
sourceRef: stringField(database, "sourceRef", `${path}.specRegistry.database`),
sourceKey: stringField(database, "sourceKey", `${path}.specRegistry.database`),
schema: postgresIdentifierField(database, "schema", `${path}.specRegistry.database`),
table: postgresIdentifierField(database, "table", `${path}.specRegistry.database`),
},
builtInConfigRefs: stringArrayField(specRegistry, "builtInConfigRefs", `${path}.specRegistry`),
},
temporal: {