From 2d86bbc10abc9e75d9d967dd1bf4ee7451edc27e Mon Sep 17 00:00:00 2001 From: leechenghsiu Date: Wed, 22 Jul 2026 16:08:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=9C=A8=20server=20get=20/=20?= =?UTF-8?q?list=20=E9=9C=B2=E5=87=BA=E6=A9=9F=E5=99=A8=20OS=EF=BC=88ZEA-10?= =?UTF-8?q?206=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent 拿到一個 server ID 之後無從判斷它是不是 ZeaburOS——`server get` / `server list` 都不露出這個資訊。結果 skills 只能靠反推:打一個 kubectl 指令看它會不會 `command not found`。這浪費一次 SSH round trip,而且在「要不要 在這台建專案」之前根本判斷不了。 `hasK3s` 早就在 GraphQL schema 上,純 CLI 端補齊即可,後端零改動。query 由 struct tag 反射生成,所以 model 加欄位就自動帶進 query。 - `server get` / `server list` 加 OS 欄(ZeaburOS / Ubuntu)。 - JSON 輸出除了原始 `HasK3s`,另外導出 `os` 字串——這是本次的主要目的, 讓呼叫端不必自己記三態語意,表格顯示只是附帶。 `hasK3s` 是三態,第三態是陷阱:`null` 代表 legacy server,而它**是** ZeaburOS(後端從 certificate data 推導,但 GraphQL 綁的是原始欄位,推導不會 上 wire)。所以判斷式是 `== false`,不是 `!hasK3s`——後者會把所有舊機器掃進 來、悄悄從部署目標裡消失。與 dashboard 的 `isCleanMachine()` 契約一致。 順帶修資源欄位的兩個顯示問題: - total 為 0 時印 `—` 而非 `0/0 MB`。零總量是「未測得」的契約(真實機器不可能 0 核),印 `0/0` 會讀成機器出事,而不是指標還沒收集到。乾淨 VPS 現在也會回 傳 CPU/RAM/Disk 了,這條路徑才開始有機會踩到。 - CPU 標上單位 `m`。後端回的是 millicore,4 核機器讀出來是 4000,沒有單位會 看成 4000 核。與 dashboard 的 k8s 風格顯示一致。 Co-Authored-By: Claude Opus 4.8 --- pkg/model/server.go | 85 ++++++++++++++++++++++++-- pkg/model/server_test.go | 128 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 pkg/model/server_test.go diff --git a/pkg/model/server.go b/pkg/model/server.go index 38e296f..c97679a 100644 --- a/pkg/model/server.go +++ b/pkg/model/server.go @@ -1,10 +1,59 @@ package model import ( + "encoding/json" "fmt" "time" ) +// OS labels reported for a server. Renting and reinstalling provision the base +// OS only, so a machine without Zeabur services is a first-class outcome — it +// just cannot host Zeabur projects until they are installed. +const ( + OSZeaburOS = "ZeaburOS" + OSUbuntu = "Ubuntu" +) + +// serverOS derives the machine's OS from hasK3s. +// +// hasK3s is three-state, and the third state is the trap: +// +// - true — Zeabur services are installed. +// - false — explicitly none. +// - nil — a legacy server, which *does* have them. The backend infers this +// from the server's certificate data, but the GraphQL field exposes the raw +// column, so the inference never reaches the wire. +// +// So the test is `== false`, never `!hasK3s`: the latter would sweep every +// legacy server in and mislabel it as a plain VPS. Mirrors isCleanMachine() in +// the dashboard. +// +// Ubuntu is what Zeabur provisions when renting or reinstalling, so it is +// accurate for every machine that reached this state through Zeabur. A +// self-registered server running some other distribution would be labelled +// Ubuntu too, but such a server has Zeabur services installed at registration +// and therefore reports ZeaburOS instead. +func serverOS(hasK3s *bool) string { + if hasK3s != nil && !*hasK3s { + return OSUbuntu + } + return OSZeaburOS +} + +// formatUsage renders a used/total pair, or an em dash when the total is zero. +// +// Zero total is the contract for "not measured" — a real machine cannot have +// zero cores, zero memory or zero disk. Printing "0/0" would read as a machine +// in trouble rather than one whose metrics have not been collected yet. +// The unit is always spelled out: the backend reports CPU in millicores, so a +// 4-core machine reads as 4000 and would otherwise look like 4000 cores. +func formatUsage(used, total int, unit string) string { + if total == 0 { + return "—" + } + return fmt.Sprintf("%d/%d %s", used, total, unit) +} + type Server struct { ID string `graphql:"_id"` Country *string `graphql:"country"` @@ -74,7 +123,7 @@ type ServerDetail struct { Name string `graphql:"name"` IP string `graphql:"ip"` SSHPort int `graphql:"sshPort"` - SSHUsername *string `graphql:"sshUsername"` + SSHUsername *string `graphql:"sshUsername"` Country *string `graphql:"country"` City *string `graphql:"city"` Continent *string `graphql:"continent"` @@ -84,6 +133,18 @@ type ServerDetail struct { Status ServerStatus `graphql:"status"` ProviderInfo *ServerProviderInfo `graphql:"providerInfo"` Events []ServerEvent `graphql:"events"` + HasK3s *bool `graphql:"hasK3s"` +} + +// MarshalJSON adds the derived `os` field so machine-readable output states the +// machine's kind outright, instead of leaving every caller to rediscover that +// hasK3s is three-state. +func (s ServerDetail) MarshalJSON() ([]byte, error) { + type alias ServerDetail + return json.Marshal(struct { + alias + OS string `json:"os"` + }{alias(s), serverOS(s.HasK3s)}) } type ServerProviderInfo struct { @@ -98,7 +159,7 @@ type ServerEvent struct { } func (s *ServerDetail) Header() []string { - return []string{"ID", "Name", "IP", "Provider", "Location", "Status", "VM Status", "CPU", "Memory", "Disk", "Managed", "Created At"} + return []string{"ID", "Name", "IP", "Provider", "Location", "Status", "VM Status", "OS", "CPU", "Memory", "Disk", "Managed", "Created At"} } func (s *ServerDetail) Rows() [][]string { @@ -126,9 +187,9 @@ func (s *ServerDetail) Rows() [][]string { managed = "Yes" } - cpu := fmt.Sprintf("%d/%d", s.Status.UsedCPU, s.Status.TotalCPU) - memory := fmt.Sprintf("%d/%d MB", s.Status.UsedMemory, s.Status.TotalMemory) - disk := fmt.Sprintf("%d/%d MB", s.Status.UsedDisk, s.Status.TotalDisk) + cpu := formatUsage(s.Status.UsedCPU, s.Status.TotalCPU, "m") + memory := formatUsage(s.Status.UsedMemory, s.Status.TotalMemory, "MB") + disk := formatUsage(s.Status.UsedDisk, s.Status.TotalDisk, "MB") return [][]string{ { @@ -139,6 +200,7 @@ func (s *ServerDetail) Rows() [][]string { location, status, s.Status.VMStatus, + serverOS(s.HasK3s), cpu, memory, disk, @@ -217,12 +279,22 @@ type ServerListItem struct { ProvisioningStatus *string `graphql:"provisioningStatus"` Status ServerStatus `graphql:"status"` ProviderInfo *ServerProviderInfo `graphql:"providerInfo"` + HasK3s *bool `graphql:"hasK3s"` +} + +// MarshalJSON adds the derived `os` field. See ServerDetail.MarshalJSON. +func (s ServerListItem) MarshalJSON() ([]byte, error) { + type alias ServerListItem + return json.Marshal(struct { + alias + OS string `json:"os"` + }{alias(s), serverOS(s.HasK3s)}) } type ServerListItems []ServerListItem func (s ServerListItems) Header() []string { - return []string{"ID", "Name", "IP", "Provider", "Location", "Status", "VM Status"} + return []string{"ID", "Name", "IP", "Provider", "Location", "Status", "VM Status", "OS"} } func (s ServerListItems) Rows() [][]string { @@ -255,6 +327,7 @@ func (s ServerListItems) Rows() [][]string { location, status, item.Status.VMStatus, + serverOS(item.HasK3s), } } return rows diff --git a/pkg/model/server_test.go b/pkg/model/server_test.go new file mode 100644 index 0000000..5bba111 --- /dev/null +++ b/pkg/model/server_test.go @@ -0,0 +1,128 @@ +package model_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zeabur/cli/pkg/model" +) + +func ptr[T any](v T) *T { return &v } + +// indexOf returns the column index of name in header, failing the test when the +// column is missing — keeps the assertions below readable and order-independent. +func indexOf(t *testing.T, header []string, name string) int { + t.Helper() + for i, h := range header { + if h == name { + return i + } + } + t.Fatalf("column %q not found in header %v", name, header) + return -1 +} + +func TestServerListItemsOSColumn(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hasK3s *bool + want string + }{ + {name: "k3s installed", hasK3s: ptr(true), want: "ZeaburOS"}, + {name: "explicitly no k3s", hasK3s: ptr(false), want: "Ubuntu"}, + // A legacy server predates the field and does have Zeabur services; the + // backend infers that from certificate data but exposes the raw column, + // so null must never be read as "no k3s". + {name: "legacy server reports null", hasK3s: nil, want: "ZeaburOS"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + items := model.ServerListItems{{ID: "abc", Name: "s", HasK3s: tt.hasK3s}} + rows := items.Rows() + require.Len(t, rows, 1) + assert.Equal(t, tt.want, rows[0][indexOf(t, items.Header(), "OS")]) + }) + } +} + +func TestServerDetailOSColumn(t *testing.T) { + t.Parallel() + + server := &model.ServerDetail{ID: "abc", Name: "s", HasK3s: ptr(false)} + rows := server.Rows() + require.Len(t, rows, 1) + assert.Equal(t, "Ubuntu", rows[0][indexOf(t, server.Header(), "OS")]) +} + +func TestServerDetailResourceColumns(t *testing.T) { + t.Parallel() + + t.Run("renders usage when measured", func(t *testing.T) { + t.Parallel() + + server := &model.ServerDetail{ + Status: model.ServerStatus{ + UsedCPU: 1000, TotalCPU: 4000, + UsedMemory: 512, TotalMemory: 2048, + UsedDisk: 3000, TotalDisk: 40000, + }, + } + row := server.Rows()[0] + header := server.Header() + // CPU is millicores, so the unit must be spelled out — a 4-core machine + // reports 4000 and would otherwise read as 4000 cores. + assert.Equal(t, "1000/4000 m", row[indexOf(t, header, "CPU")]) + assert.Equal(t, "512/2048 MB", row[indexOf(t, header, "Memory")]) + assert.Equal(t, "3000/40000 MB", row[indexOf(t, header, "Disk")]) + }) + + // A real machine cannot have zero cores, so a zero total means the metrics + // were never collected. Printing "0/0" would read as a machine in trouble. + t.Run("renders a dash when not measured", func(t *testing.T) { + t.Parallel() + + server := &model.ServerDetail{} + row := server.Rows()[0] + header := server.Header() + assert.Equal(t, "—", row[indexOf(t, header, "CPU")]) + assert.Equal(t, "—", row[indexOf(t, header, "Memory")]) + assert.Equal(t, "—", row[indexOf(t, header, "Disk")]) + }) +} + +func TestServerJSONCarriesOS(t *testing.T) { + t.Parallel() + + t.Run("detail", func(t *testing.T) { + t.Parallel() + + data, err := json.Marshal(&model.ServerDetail{ID: "abc", HasK3s: ptr(false)}) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, "Ubuntu", got["os"]) + assert.Equal(t, "abc", got["ID"]) + // The raw three-state field stays available for callers that need it. + assert.Equal(t, false, got["HasK3s"]) + }) + + t.Run("list", func(t *testing.T) { + t.Parallel() + + data, err := json.Marshal(model.ServerListItems{{ID: "abc", HasK3s: ptr(true)}}) + require.NoError(t, err) + + var got []map[string]any + require.NoError(t, json.Unmarshal(data, &got)) + require.Len(t, got, 1) + assert.Equal(t, "ZeaburOS", got[0]["os"]) + }) +}