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"]) + }) +}