Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 79 additions & 6 deletions pkg/model/server.go
Original file line number Diff line number Diff line change
@@ -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"`
Expand Down Expand Up @@ -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"`
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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{
{
Expand All @@ -139,6 +200,7 @@ func (s *ServerDetail) Rows() [][]string {
location,
status,
s.Status.VMStatus,
serverOS(s.HasK3s),
cpu,
memory,
disk,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -255,6 +327,7 @@ func (s ServerListItems) Rows() [][]string {
location,
status,
item.Status.VMStatus,
serverOS(item.HasK3s),
}
}
return rows
Expand Down
128 changes: 128 additions & 0 deletions pkg/model/server_test.go
Original file line number Diff line number Diff line change
@@ -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"])
})
}