☰
Terminus 节点状态增强:为 `/system/node-status` 注入主机明细字段的完整实现剖析
2026/10/13 1:52:56 网站建设 项目流程
  • 云原生
  • 后端
  • 运维

【免费下载链接】Terminus

Open-Source Personal Cloud OS for Always-On Agents

项目地址:https://gitcode.com/gh_mirrors/terminus2/Terminus
点击查看免费下载

本文以 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 keyGo 字段类型数据来源未知/缺失时的表现
os_archOsArchstringState.OsArch""
os_kernelOsKernelstringState.OsKernel(新增,State 上带json:"-")""
hostIpHostIPstringState.HostIP""
wiredConnectedWiredConnectedboolState.WiredConnectedfalse
wifiSSIDWifiSSIDstringState.WifiSSID(State 上为*string)未连接/为空时整个 key 省略(omitempty)

其中os_arch、hostIp、wiredConnected、wifiSSID的命名与/system/status中已有的同名 key 保持一致;os_kernel是 node-status 独占字段,不允许出现在/system/status上。

二、架构决策:从 5 秒快照投影,不做逐请求探测

实现计划明确了一件事:不加新路由、不动鉴权、不增加按请求执行的探测。原因很直观——olaresd的状态刷新循环(约每 5 秒一次)本来就在采集这些信息:

  1. 机器信息: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 上。
  2. 网络信息: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-statusTask 2、3
os_kernel仅出现在 node-statusTask 1(json:"-")、Task 2、3
hostIpTask 2、3
wiredConnected布尔Task 2、3
wifiSSID断开时 omitemptyTask 2、3
从GetMachineInfo持久化内核Task 1
/system/status线格式不变Task 1 测试
空/未知 → 空字符串、wiredConnected=falseTask 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' -v

daemon模块通过 go.mod 中的 replace 指令引用本地cli模块,因此state类型别名(daemon/pkg/cluster/state/current.go中的type state = clistate.State)让 daemon 与 CLI 共享完全相同的线格式类型。

七、实现要点速查与排查指南

7.1 关键文件与职责

文件职责
cli/pkg/daemon/state/types.goState结构体,新增OsKernel string json:"-";/system/status线格式的唯一权威定义
cli/pkg/daemon/state/types_test.go断言OsKernel不出现在 State JSON 中
daemon/pkg/cluster/state/current.gorefreshCurrentStatus每 5 秒刷新,持久化OsKernel、HostIP、WiredConnected、WifiSSID
daemon/pkg/cluster/state/snapshot.go不可变观测快照的发布与读取(Snapshot())
daemon/pkg/cluster/state/utils.goGetMachineInfo:解析并缓存olares-cli osinfo show的输出
daemon/pkg/cluster/nodestatus/nodestatus.goStatus结构体与Build映射逻辑
daemon/pkg/cluster/nodestatus/detail_test.goBuild的映射 / omitempty / 空快照单测
daemon/internel/apiserver/handlers/handler_node_status.goGetNodeStatus处理器
daemon/internel/apiserver/handlers/handler_cluster.golocalNodeStatus:快照 + 身份 + 能力探测 →Build
daemon/internel/apiserver/handlers/handler_node_status_test.goHTTP 线格式测试
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

项目地址:https://gitcode.com/gh_mirrors/terminus2/Terminus
点击查看免费下载

相关推荐

上一篇:Tracy Profiler:如何快速定位游戏帧率卡顿和热点函数
下一篇:Resgate客户端协议详解:WebSocket通信与自动重连机制

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询