- 云原生
- 后端
- 运维
【免费下载链接】Terminus
Open-Source Personal Cloud OS for Always-On Agents
本文以 Terminus 仓库中
daemon/docs/superpowers/plans/2026-08-07-node-status-host-fields.md实现计划及配套设计规格为骨架,结合cli/pkg/daemon/state、daemon/pkg/cluster/state、daemon/pkg/cluster/nodestatus与daemon/internel/apiserver/handlers的源码与测试,深入剖析 Olares 守护进程如何在不改变/system/status公开 JSON 的前提下,将 CPU 架构、内核版本、局域网 IP、有线连接状态与 Wi-Fi SSID 五个主机明细字段投影到节点详情接口。读完本文,你将掌握该功能的字段契约、快照数据流、json:"-"隔离技巧、测试驱动实现路径,以及如何验证与排查这条链路。
一、背景与目标:节点详情页需要的主机事实
Olares(Terminus)的每个节点由olaresd守护进程自报状态,集群控制节点通过聚合各节点的本地报告获得集群视图。节点详情页需要展示五类主机层面的信息:
- 内核 / CPU 架构(如
amd64、arm64) - 内核版本(如
6.8.0-60-generic) - 内网 LAN IP
- 有线连接状态
- Wi-Fi SSID
设计规格(daemon/docs/superpowers/specs/2026-08-07-node-status-host-fields-design.md)给出了明确的约束:只扩展GET /system/node-status,绝不改动/system/status的线格式。原因在于/system/status是 TermiPass 与olares-cli已经轮询的遗留接口,其 JSON 形状的任何变化都会波及 CLI、前端与 mDNS 客户端等所有消费方——cli/pkg/daemon/state/types.go中明确写着所有字段的 JSON tag 必须逐字节对齐线格式,重命名 JSON key 必须先更新每一个消费者。
最终落地的五个字段及其语义如下(来自设计规格的 Wire format 表):
| JSON key | Go 字段 | 类型 | 数据来源 | 未知/缺失时的表现 |
|---|---|---|---|---|
os_arch | OsArch | string | State.OsArch | "" |
os_kernel | OsKernel | string | State.OsKernel(新增,State 上带json:"-") | "" |
hostIp | HostIP | string | State.HostIP | "" |
wiredConnected | WiredConnected | bool | State.WiredConnected | false |
wifiSSID | WifiSSID | string | State.WifiSSID(State 上为*string) | 未连接/为空时整个 key 省略(omitempty) |
其中os_arch、hostIp、wiredConnected、wifiSSID的命名与/system/status中已有的同名 key 保持一致;os_kernel是 node-status 独占字段,不允许出现在/system/status上。
二、架构决策:从 5 秒快照投影,不做逐请求探测
实现计划明确了一件事:不加新路由、不动鉴权、不增加按请求执行的探测。原因很直观——olaresd的状态刷新循环(约每 5 秒一次)本来就在采集这些信息:
- 机器信息:
GetMachineInfo通过子进程调用olares-cli osinfo show解析OS_TYPE、OS_INFO、OS_ARCH、OS_VERSION、OS_KERNEL。实现位于daemon/pkg/cluster/state/utils.go(约 156 行起):结果在进程生命周期内是静态的,所以首次成功解析后会被缓存(machineInfoCached),避免每个 5 秒 tick 都 fork 一次子进程;若 CLI 尚未就绪则返回空值并在下一个 tick 重试。此前osKernel已经被解析出来却被丢弃(占位符_),本次改造的核心之一就是把它持久化到 State 上。 - 网络信息:
nets.GetInternalIpv4Addr()枚举内网接口;utils.ManagedDeviceStatus(ctx)用单次nmcli device status枚举网络设备(daemon/pkg/utils/network_linux.go),按设备类型区分wifi(置WifiConnected=true并记录WifiSSID)与ethernet(置WiredConnected=true);macOS 上则按接口名前缀(wl/en/eth)推断,SSID 通过utils.WifiName()获取(daemon/pkg/utils/wifi.go,Linux 走iwgetid --raw,macOS 走 airport-I);HostIP来自nets.GetHostIp(),并会校验/修复/etc/hosts中的域名与 IP 冲突。
数据流可以概括为(设计规格原图):
status refresh (≈5s) └─ GetMachineInfo → OsArch, OsKernel (以及已有 OS 字段) └─ network probe → HostIP, WiredConnected, WifiSSID │ ▼ CurrentState 快照 │ ▼ localNodeStatus → nodestatus.Build → GET /system/node-status刷新出的CurrentState通过snapshot.go的Snapshot()发布为不可变观测值:每次读取都会复制一份,绝不把共享切片交给调用方;observedAt记录的是刷新完成时刻而非响应生成时刻,避免把陈旧状态伪造成"刚刚"。nodestatus.Build只接受"身份 + 快照 + 能力探测结果"三样输入,输出即节点本地视图——这保证了任何一次响应都对应同一个观测快照,不会在刷新中途拼凑出前后矛盾的报告。
三、Task 1:把内核版本持久化到 State,且不污染/system/status
3.1 先写失败测试,锁定线格式契约
实现计划的第一步是测试先行。cli/pkg/daemon/state/types_test.go中新增了TestStateOsKernelIsExcludedFromSystemStatusWire:
func TestStateOsKernelIsExcludedFromSystemStatusWire(t *testing.T) { raw, err := json.Marshal(State{ OsArch: "amd64", OsKernel: "6.8.0-60-generic", HostIP: "10.0.0.2", }) if err != nil { t.Fatal(err) } var got map[string]any if err := json.Unmarshal(raw, &got); err != nil { t.Fatal(err) } if _, exists := got["os_kernel"]; exists { t.Fatalf("os_kernel must not appear on /system/status wire, got %#v", got) } if _, exists := got["OsKernel"]; exists { t.Fatalf("OsKernel must not appear on /system/status wire, got %#v", got) } if got["os_arch"] != "amd64" { t.Fatalf("os_arch = %#v, want amd64", got["os_arch"]) } if got["hostIp"] != "10.0.0.2" { t.Fatalf("hostIp = %#v, want 10.0.0.2", got["hostIp"]) } }在实现前运行它必然失败(State.OsKernel未定义或编译错误),这正是 TDD 的红灯阶段。这个测试同时锁定了三层语义:os_kernel与OsKernel都不得出现在/system/status线格式中,而os_arch、hostIp必须保持存在且取值正确。
3.2 在 State 结构体中用json:"-"隔离
在cli/pkg/daemon/state/types.go中,位于OsVersion字段块之后、CpuInfo之前,插入:
// OsKernel is the kernel release string from GetMachineInfo (OS_KERNEL). // It is daemon-internal: excluded from /system/status so that endpoint's // wire format stays unchanged. /system/node-status projects it as os_kernel. OsKernel string `json:"-"`json:"-"是 Go 标准库序列化的"隐身衣":该字段完全不出现在 JSON 中。这与文件中已有的模式一脉相承——InstallFinishedTime、ExternalIPProbeTime、InstallingProgressNum、UpgradingProgressNum、UpgradingDownloadProgressNum都是带json:"-"的守护进程内部字段,既参与状态机逻辑,又不向线格式泄漏。这样OsKernel就能安全地活在内存快照里,由 node-status 单独投影为os_kernel。
设计权衡:为什么不在
/system/status上直接加os_kernel,omitempty?规格的 Out of scope 部分写得很清楚——改变/system/status公开 JSON(包括在此新增os_kernel)是明确不做的事。遗留端点要保持形状稳定,新增能力一律走新端点。
3.3 刷新循环不再丢弃内核值
daemon/pkg/cluster/state/current.go的refreshCurrentStatus中,原来的:
osType, osInfo, osArch, osVersion, _, err := GetMachineInfo(ctx)改为:
osType, osInfo, osArch, osVersion, osKernel, err := GetMachineInfo(ctx)并在其他 OS 赋值附近新增CurrentState.OsKernel = osKernel。实际仓库代码中,这一行落在daemon/pkg/cluster/state/current.go的CurrentState.OsArch = osArch之后(约 152 行),与OsInfo、OsVersion、OsType的赋值并列,共同构成每次刷新的机器信息落盘。注意GetMachineInfo失败时设计规格的约定是:保留现有 warning 日志,OsArch/OsKernel保持为空,接口仍返回 HTTP 200——刷新循环异常不能拖垮状态读取。
四、Task 2:nodestatus.Build投影主机字段
4.1 扩展现有的三组单元测试
daemon/pkg/cluster/nodestatus/detail_test.go中追加了三个测试,覆盖完整映射、Wi-Fi 省略与未知留空三类场景:
完整映射——五个值全部就位时逐一断言:
func TestBuildReportsHostDetailFields(t *testing.T) { ssid := "olares-lab" st := clistate.State{ TerminusState: clistate.TerminusRunning, OsArch: "amd64", OsKernel: "6.8.0-60-generic", HostIP: "10.0.0.2", WiredConnected: true, WifiSSID: &ssid, } got := Build(Identity{}, st, nil, time.Now()) if got.OsArch != "amd64" { t.Errorf("os_arch = %q, want amd64", got.OsArch) } if got.OsKernel != "6.8.0-60-generic" { t.Errorf("os_kernel = %q, want 6.8.0-60-generic", got.OsKernel) } if got.HostIP != "10.0.0.2" { t.Errorf("hostIp = %q, want 10.0.0.2", got.HostIP) } if !got.WiredConnected { t.Errorf("wiredConnected = false, want true") } if got.WifiSSID != ssid { t.Errorf("wifiSSID = %q, want %q", got.WifiSSID, ssid) } }Wi-Fi 断开时省略 key——直接对json.Marshal(Build(...))的结果做 key 级检查:
func TestBuildOmitsWifiSSIDWhenDisconnected(t *testing.T) { st := clistate.State{ TerminusState: clistate.TerminusRunning, WiredConnected: true, HostIP: "10.0.0.2", } raw, err := json.Marshal(Build(Identity{}, st, nil, time.Now())) if err != nil { t.Fatalf("marshal: %v", err) } var fields map[string]json.RawMessage if err := json.Unmarshal(raw, &fields); err != nil { t.Fatalf("decode %s: %v", raw, err) } if _, ok := fields["wifiSSID"]; ok { t.Errorf("wifiSSID must be omitted when disconnected: %s", raw) } for _, key := range []string{"os_arch", "os_kernel", "hostIp", "wiredConnected"} { if _, ok := fields[key]; !ok { t.Errorf("field %q missing: %s", key, raw) } } }未知值一律留空、不发明占位符:
func TestBuildLeavesUnknownHostDetailsEmpty(t *testing.T) { got := Build(Identity{}, clistate.State{TerminusState: clistate.TerminusRunning}, nil, time.Now()) if got.OsArch != "" || got.OsKernel != "" || got.HostIP != "" || got.WifiSSID != "" { t.Errorf("want empty host detail strings: %+v", got) } if got.WiredConnected { t.Errorf("wiredConnected = true, want false when unknown") } }4.2Status结构体与Build的映射实现
daemon/pkg/cluster/nodestatus/nodestatus.go中,Status在OlaresdVersion之后、ObservedAt之前新增:
// Host detail fields projected from the local state snapshot. Keys match // /system/status where those already exist; os_kernel is node-status only. OsArch string `json:"os_arch"` OsKernel string `json:"os_kernel"` HostIP string `json:"hostIp"` WiredConnected bool `json:"wiredConnected"` WifiSSID string `json:"wifiSSID,omitempty"`Build中,Wi-Fi 的指针解引用被显式处理——只有State.WifiSSID非 nil 时才取字符串值:
var wifiSSID string if st.WifiSSID != nil { wifiSSID = *st.WifiSSID }并在返回的结构体字面量中补齐映射:
OsArch: st.OsArch, OsKernel: st.OsKernel, HostIP: st.HostIP, WiredConnected: st.WiredConnected, WifiSSID: wifiSSID,注意Status.WifiSSID是string(非指针)配合omitempty,State.WifiSSID则是*string——指针在 State 侧用于表达"未采集到"与"空字符串"的区分,而线格式侧只要为空即整体省略 key。omitempty对空字符串自动生效,因此 nil 指针解引用后的空字符串会在序列化时被丢弃,测试TestBuildOmitsWifiSSIDWhenDisconnected验证的正是这一点。
4.3 关键设计约束:未知就是未知
nodestatus包的包级注释点明了整个模块的哲学:"Anything this node cannot confirm is reported as unknown rather than guessed, so that a caller aggregating several nodes never turns a missing fact into a false one."(本节点无法确认的东西一律报为 unknown,绝不猜测,否则聚合多个节点时会把缺失的事实变成错误的事实。)主机明细字段同样遵循此原则:""表示未知,false表示未连接,永不填充占位值。
五、Task 3:HTTP 线格式覆盖与鉴权行为
5.1 路由与鉴权
daemon/internel/apiserver/handlers/system_group.go中注册:
system.Get("/node-status", handlers.RequireAuthorizationOrOwnerSignature(handlers.RequireLocal(handlers.GetNodeStatus)))RequireLocal限定本机可达,RequireAuthorizationOrOwnerSignature要求携带有效鉴权头或 owner 签名——这解释了测试文件中为何有"无鉴权返回 403"与"owner 签名可执行预检"两种用例。处理器GetNodeStatus(handler_node_status.go)只做一件事:取快照 → 构建身份 → 调用localNodeStatus返回OkJSON,真正的组装发生在handler_cluster.go的localNodeStatus:
func localNodeStatus(ctx context.Context) nodestatus.Status { snap, observedAt := currentStateSnapshot() id := identityFrom(ctx, snap) return nodestatus.Build( id, snap, nodestatus.Detect(ctx, nodestatus.ProbeInput{Identity: id, State: snap}), observedAt, ) }一次请求只取一个快照贯穿全程——避免刷新循环中途落地导致响应前后矛盾。
5.2 处理器级测试:种子快照 + 逐 key 断言
daemon/internel/apiserver/handlers/handler_node_status_test.go中,TestNodeStatusReportsTheDetailFields通过withCurrentState安装带全部字段的快照,然后调用/system/node-status:
ssid := "olares-lab" withCurrentState(t, clistate.State{ TerminusState: clistate.TerminusRunning, Memory: "128 G", Disk: "3725 G", OlaresdVersion: &version, OsArch: "amd64", OsKernel: "6.8.0-60-generic", HostIP: "10.0.0.2", WiredConnected: true, WifiSSID: &ssid, }, time.Now())解码响应后,要求 JSONdata对象同时包含十个 key:
for _, key := range []string{"memory", "disk", "olaresdVersion", "observedAt", "deviceType", "os_arch", "os_kernel", "hostIp", "wiredConnected", "wifiSSID"} { if _, ok := env.Data[key]; !ok { t.Errorf("field %q missing from the node wire format: %s", key, body) } }并反向断言不存在的字段(Olares 中不存在节点 id,也不存在字节计数字段):
for _, absent := range []string{"nodeId", "node_id", "memoryBytes", "diskBytes"} { if _, ok := env.Data[absent]; ok { t.Errorf("field %q is not something this node knows: %s", absent, body) } }配套的TestNodeStatusOmitsWifiSSIDWhenDisconnected则验证断开场景下wifiSSID从 HTTP body 中消失、hostIp与wiredConnected保持正确。
5.3 集群聚合自动受益
设计规格指出,集群汇总逻辑扇出到各节点/system/node-status时会自动在每台节点的 body 中收到新字段——无需改动任何路由、鉴权或聚合协议。控制节点通过http://<node-ip>:18088/system/node-status(nodeStatusURL)拉取各工作节点报告,字段随每台节点各自的 daemon 版本逐步演进,这也正是olaresdVersion字段存在的原因:不同节点 daemon 版本漂移是"某节点提供某能力而邻居没有"的常见解释,详情页需要显示它。
六、测试矩阵与回归保障
实现计划在末尾给出了 Spec 覆盖清单,映射关系如下:
| Spec 需求 | 覆盖 Task |
|---|---|
os_arch出现在 node-status | Task 2、3 |
os_kernel仅出现在 node-status | Task 1(json:"-")、Task 2、3 |
hostIp | Task 2、3 |
wiredConnected布尔 | Task 2、3 |
wifiSSID断开时 omitempty | Task 2、3 |
从GetMachineInfo持久化内核 | Task 1 |
/system/status线格式不变 | Task 1 测试 |
空/未知 → 空字符串、wiredConnected=false | Task 2 |
| 处理器 HTTP 覆盖 | Task 3 |
| 路由/鉴权/聚合不变 | 明确 Out of scope |
三层测试构成了完整防线:状态层测试(cli/pkg/daemon/state/types_test.go)保证 State 序列化契约不变;映射层测试(daemon/pkg/cluster/nodestatus/detail_test.go)验证Build的字段映射、omitempty与空快照行为;处理器层测试(handler_node_status_test.go)验证真实 HTTP 响应 body 的 key 集合。此外还有既有测试的回归保护,例如TestSystemStatusIsUnchanged确保遗留端点持续应答且载荷形状不变。
运行测试的命令(以仓库布局为例,cli与daemon是两个独立 Go module):
go test ./pkg/daemon/state/ -run TestStateOsKernelIsExcludedFromSystemStatusWire -v go test ./pkg/cluster/nodestatus/ -run 'TestBuildReportsHostDetailFields|TestBuildOmitsWifiSSIDWhenDisconnected|TestBuildLeavesUnknownHostDetailsEmpty' -v go test ./internel/apiserver/handlers/ -run 'TestNodeStatusReportsTheDetailFields|TestNodeStatusOmitsWifiSSIDWhenDisconnected' -vdaemon模块通过 go.mod 中的 replace 指令引用本地cli模块,因此state类型别名(daemon/pkg/cluster/state/current.go中的type state = clistate.State)让 daemon 与 CLI 共享完全相同的线格式类型。
七、实现要点速查与排查指南
7.1 关键文件与职责
| 文件 | 职责 |
|---|---|
cli/pkg/daemon/state/types.go | State结构体,新增OsKernel string json:"-";/system/status线格式的唯一权威定义 |
cli/pkg/daemon/state/types_test.go | 断言OsKernel不出现在 State JSON 中 |
daemon/pkg/cluster/state/current.go | refreshCurrentStatus每 5 秒刷新,持久化OsKernel、HostIP、WiredConnected、WifiSSID |
daemon/pkg/cluster/state/snapshot.go | 不可变观测快照的发布与读取(Snapshot()) |
daemon/pkg/cluster/state/utils.go | GetMachineInfo:解析并缓存olares-cli osinfo show的输出 |
daemon/pkg/cluster/nodestatus/nodestatus.go | Status结构体与Build映射逻辑 |
daemon/pkg/cluster/nodestatus/detail_test.go | Build的映射 / omitempty / 空快照单测 |
daemon/internel/apiserver/handlers/handler_node_status.go | GetNodeStatus处理器 |
daemon/internel/apiserver/handlers/handler_cluster.go | localNodeStatus:快照 + 身份 + 能力探测 →Build |
daemon/internel/apiserver/handlers/handler_node_status_test.go | HTTP 线格式测试 |
daemon/internel/apiserver/handlers/system_group.go | /system/node-status路由注册与鉴权 |
7.2 排查要点
os_kernel出现在/system/status里?检查State.OsKernel的 tag 是否被误删——它必须保持json:"-";/system/status的公开 JSON 以cli/pkg/daemon/state/types.go为准。wifiSSID未按预期省略?确认Status.WifiSSID带omitempty,且Build只在st.WifiSSID != nil时取值;线格式上空字符串 +omitempty才会让 key 消失。- 字段全是空串 /
wiredConnected=false?先看GetMachineInfo是否解析成功(CLI 未就绪会返回空并重试),再看网络探测是否枚举到设备——未知即空是设计行为,不是 bug。 - 响应时间戳可疑?
observedAt是刷新完成时刻而非响应时刻;快照发布与读取机制(snapshot.go)保证读者绝不会排队等待持锁的刷新循环。
- 云原生
- 后端
- 运维
【免费下载链接】Terminus
Open-Source Personal Cloud OS for Always-On Agents
相关推荐
5步完成iOS激活锁绕过:Applera1n专业工具终极指南
5步完成iOS激活锁绕过:Applera1n专业工具终极指南 忘记Apple ID密码导致iPhone无法激活?购买的二手设备被激活锁锁定无法使用?Appler
API网关后端云原生微服务KubeEdge 节点任务状态结构增强:v1alpha2 下的 Node Job / Node Task 状态模型设计解析
KubeEdge 节点任务状态结构增强:v1alpha2 下的 Node Job / Node Task 状态模型设计解析 导读 本篇文章以 KubeEdge
云原生边缘计算物联网容器编排边缘网关Apache APISIX node-status 插件详解:通过 `/apisix/status` 接口实时查询网关节点状态
Apache APISIX node status 插件详解:通过 /apisix/status 接口实时查询网关节点状态 node status 是 Apac
后端微服务云原生
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考