Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve k8s status #563

Merged
merged 19 commits into from
Jul 30, 2024
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ k8s_*.txt
/docs/tools/.sphinx/warnings.txt
/docs/tools/.sphinx/.wordlist.dic
/docs/tools/.sphinx/.doctrees/
/docs/tools/.sphinx/node_modules
/docs/tools/.sphinx/node_modules
4 changes: 4 additions & 0 deletions docs/src/_parts/commands/k8s_status.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Retrieve the current status of the cluster

### Synopsis

Retrieve the current status of the cluster as well as deployment status of core features.

```
k8s status [flags]
```
Expand Down
131 changes: 61 additions & 70 deletions src/k8s/api/v1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ package apiv1
import (
"fmt"
"strings"

"gopkg.in/yaml.v2"
"time"
)

type ClusterRole string
Expand Down Expand Up @@ -42,6 +41,28 @@ type NodeStatus struct {
DatastoreRole DatastoreRole `json:"datastore-role,omitempty"`
}

// FeatureStatus encapsulates the deployment status of a feature.
type FeatureStatus struct {
// Enabled shows whether or not the deployment of manifests for a status was successful.
Enabled bool
// Message contains information about the status of a feature. It is only supposed to be human readable and informative and should not be programmatically parsed.
Message string
// Version shows the version of the deployed feature.
Version string
// UpdatedAt shows when the last update was done.
UpdatedAt time.Time
}

func (f FeatureStatus) String() string {
if f.Message != "" {
return f.Message
}
if f.Enabled {
return "enabled"
}
return "disabled"
}

type Datastore struct {
Type string `json:"type,omitempty"`
Servers []string `json:"servers,omitempty" yaml:"servers,omitempty"`
Expand All @@ -54,6 +75,14 @@ type ClusterStatus struct {
Members []NodeStatus `json:"members,omitempty"`
Config UserFacingClusterConfig `json:"config,omitempty"`
Datastore Datastore `json:"datastore,omitempty"`

DNS FeatureStatus `json:"dns,omitempty"`
Network FeatureStatus `json:"network,omitempty"`
LoadBalancer FeatureStatus `json:"load-balancer,omitempty"`
Ingress FeatureStatus `json:"ingress,omitempty"`
Gateway FeatureStatus `json:"gateway,omitempty"`
MetricsServer FeatureStatus `json:"metrics-server,omitempty"`
LocalStorage FeatureStatus `json:"local-storage,omitempty"`
}

// HaClusterFormed returns true if the cluster is in high-availability mode (more than two voter nodes).
Expand All @@ -69,94 +98,56 @@ func (c ClusterStatus) HaClusterFormed() bool {

// TICS -COV_GO_SUPPRESSED_ERROR
// we are just formatting the output for the k8s status command, it is ok to ignore failures from result.WriteString()
func (c ClusterStatus) datastoreToString() string {
result := strings.Builder{}

// Datastore
if c.Datastore.Type != "" {
result.WriteString(fmt.Sprintf(" type: %s\n", c.Datastore.Type))
// Datastore URL for external only
if c.Datastore.Type == "external" {
result.WriteString(fmt.Sprintln(" servers:"))
for _, serverURL := range c.Datastore.Servers {
result.WriteString(fmt.Sprintf(" - %s\n", serverURL))
}
return result.String()
}
}

// Datastore roles for dqlite
voters := make([]NodeStatus, 0, len(c.Members))
standBys := make([]NodeStatus, 0, len(c.Members))
spares := make([]NodeStatus, 0, len(c.Members))
for _, node := range c.Members {
switch node.DatastoreRole {
case DatastoreRoleVoter:
voters = append(voters, node)
case DatastoreRoleStandBy:
standBys = append(standBys, node)
case DatastoreRoleSpare:
spares = append(spares, node)
}
}
if len(voters) > 0 {
result.WriteString(" voter-nodes:\n")
for _, voter := range voters {
result.WriteString(fmt.Sprintf(" - %s\n", voter.Address))
}
} else {
result.WriteString(" voter-nodes: none\n")
}
if len(standBys) > 0 {
result.WriteString(" standby-nodes:\n")
for _, standBy := range standBys {
result.WriteString(fmt.Sprintf(" - %s\n", standBy.Address))
}
} else {
result.WriteString(" standby-nodes: none\n")
}
if len(spares) > 0 {
result.WriteString(" spare-nodes:\n")
for _, spare := range spares {
result.WriteString(fmt.Sprintf(" - %s\n", spare.Address))
}
} else {
result.WriteString(" spare-nodes: none\n")
}

return result.String()
}

// TODO: Print k8s version. However, multiple nodes can run different version, so we would need to query all nodes.
func (c ClusterStatus) String() string {
result := strings.Builder{}

// Status
if c.Ready {
result.WriteString("status: ready")
result.WriteString(fmt.Sprintf("%-25s %s", "cluster status:", "ready"))
} else {
result.WriteString(fmt.Sprintf("%-25s %s", "cluster status:", "not ready"))
}
result.WriteString("\n")

// Control Plane Nodes
result.WriteString(fmt.Sprintf("%-25s ", "control plane nodes:"))
if len(c.Members) > 0 {
members := make([]string, 0, len(c.Members))
for _, m := range c.Members {
members = append(members, fmt.Sprintf("%s (%s)", m.Address, m.DatastoreRole))
}
result.WriteString(strings.Join(members, ", "))
} else {
result.WriteString("status: not ready")
result.WriteString("none")
}
result.WriteString("\n")

// High availability
result.WriteString("high-availability: ")
result.WriteString(fmt.Sprintf("%-25s ", "high availability:"))
if c.HaClusterFormed() {
result.WriteString("yes")
} else {
result.WriteString("no")
}

// Datastore
result.WriteString("\n")
result.WriteString("datastore:\n")
result.WriteString(c.datastoreToString())

// Config
if !c.Config.Empty() {
b, _ := yaml.Marshal(c.Config)
result.WriteString(string(b))
// Datastore
// TODO: how to understand if the ds is running or not?
if c.Datastore.Type != "" {
result.WriteString(fmt.Sprintf("%-25s %s\n", "datastore:", c.Datastore.Type))
} else {
result.WriteString(fmt.Sprintf("%-25s %s\n", "datastore:", "disabled"))
}

result.WriteString(fmt.Sprintf("%-25s %s\n", "network:", c.Network))
result.WriteString(fmt.Sprintf("%-25s %s\n", "dns:", c.DNS))
result.WriteString(fmt.Sprintf("%-25s %s\n", "ingress:", c.Ingress))
result.WriteString(fmt.Sprintf("%-25s %s\n", "load-balancer:", c.LoadBalancer))
result.WriteString(fmt.Sprintf("%-25s %s\n", "local-storage:", c.LocalStorage))
result.WriteString(fmt.Sprintf("%-25s %s", "gateway", c.Gateway))

return result.String()
}

Expand Down
91 changes: 43 additions & 48 deletions src/k8s/api/v1/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"testing"

apiv1 "github.com/canonical/k8s/api/v1"
"github.com/canonical/k8s/pkg/utils"
. "github.com/onsi/gomega"
)

Expand Down Expand Up @@ -65,57 +64,50 @@ func TestString(t *testing.T) {
clusterStatus: apiv1.ClusterStatus{
Ready: true,
Members: []apiv1.NodeStatus{
{Name: "node1", DatastoreRole: apiv1.DatastoreRoleVoter, Address: "192.168.0.1"},
{Name: "node2", DatastoreRole: apiv1.DatastoreRoleVoter, Address: "192.168.0.2"},
{Name: "node3", DatastoreRole: apiv1.DatastoreRoleVoter, Address: "192.168.0.3"},
{Name: "node1", DatastoreRole: apiv1.DatastoreRoleVoter, Address: "192.168.0.1", ClusterRole: apiv1.ClusterRoleControlPlane},
{Name: "node2", DatastoreRole: apiv1.DatastoreRoleVoter, Address: "192.168.0.2", ClusterRole: apiv1.ClusterRoleControlPlane},
{Name: "node3", DatastoreRole: apiv1.DatastoreRoleStandBy, Address: "192.168.0.3", ClusterRole: apiv1.ClusterRoleControlPlane},
},
Config: apiv1.UserFacingClusterConfig{
Network: apiv1.NetworkConfig{Enabled: utils.Pointer(true)},
DNS: apiv1.DNSConfig{Enabled: utils.Pointer(true)},
},
Datastore: apiv1.Datastore{Type: "k8s-dqlite"},
Datastore: apiv1.Datastore{Type: "k8s-dqlite"},
Network: apiv1.FeatureStatus{Message: "enabled"},
DNS: apiv1.FeatureStatus{Message: "enabled at 192.168.0.10"},
Ingress: apiv1.FeatureStatus{Message: "enabled"},
LoadBalancer: apiv1.FeatureStatus{Message: "enabled, L2 mode"},
LocalStorage: apiv1.FeatureStatus{Message: "enabled at /var/snap/k8s/common/rawfile-storage"},
Gateway: apiv1.FeatureStatus{Message: "enabled"},
},
expectedOutput: `status: ready
high-availability: yes
datastore:
type: k8s-dqlite
voter-nodes:
- 192.168.0.1
- 192.168.0.2
- 192.168.0.3
standby-nodes: none
spare-nodes: none
network:
enabled: true
dns:
enabled: true
`,
expectedOutput: `cluster status: ready
control plane nodes: 192.168.0.1 (voter), 192.168.0.2 (voter), 192.168.0.3 (stand-by)
high availability: no
datastore: k8s-dqlite
network: enabled
dns: enabled at 192.168.0.10
ingress: enabled
load-balancer: enabled, L2 mode
local-storage: enabled at /var/snap/k8s/common/rawfile-storage
gateway enabled`,
},
{
name: "External Datastore",
clusterStatus: apiv1.ClusterStatus{
Ready: true,
Members: []apiv1.NodeStatus{
{Name: "node1", DatastoreRole: apiv1.DatastoreRoleVoter, Address: "192.168.0.1"},
},
Config: apiv1.UserFacingClusterConfig{
Network: apiv1.NetworkConfig{Enabled: utils.Pointer(true)},
DNS: apiv1.DNSConfig{Enabled: utils.Pointer(true)},
{Name: "node1", DatastoreRole: apiv1.DatastoreRoleVoter, Address: "192.168.0.1", ClusterRole: apiv1.ClusterRoleControlPlane},
},
Datastore: apiv1.Datastore{Type: "external", Servers: []string{"etcd-url1", "etcd-url2"}},
Network: apiv1.FeatureStatus{Message: "enabled"},
DNS: apiv1.FeatureStatus{Message: "enabled at 192.168.0.10"},
},
expectedOutput: `status: ready
high-availability: no
datastore:
type: external
servers:
- etcd-url1
- etcd-url2
network:
enabled: true
dns:
enabled: true
`,
expectedOutput: `cluster status: ready
control plane nodes: 192.168.0.1 (voter)
high availability: no
datastore: external
network: enabled
dns: enabled at 192.168.0.10
ingress: disabled
load-balancer: disabled
local-storage: disabled
gateway disabled`,
},
{
name: "Cluster not ready, HA not formed, no nodes",
Expand All @@ -125,13 +117,16 @@ dns:
Config: apiv1.UserFacingClusterConfig{},
Datastore: apiv1.Datastore{},
},
expectedOutput: `status: not ready
high-availability: no
datastore:
voter-nodes: none
standby-nodes: none
spare-nodes: none
`,
expectedOutput: `cluster status: not ready
control plane nodes: none
high availability: no
datastore: disabled
network: disabled
dns: disabled
ingress: disabled
load-balancer: disabled
local-storage: disabled
gateway disabled`,
},
}

Expand Down
1 change: 1 addition & 0 deletions src/k8s/cmd/k8s/k8s_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func newStatusCmd(env cmdutil.ExecutionEnvironment) *cobra.Command {
cmd := &cobra.Command{
Use: "status",
Short: "Retrieve the current status of the cluster",
Long: "Retrieve the current status of the cluster as well as deployment status of core features.",
PreRun: chainPreRunHooks(hookRequireRoot(env), hookInitializeFormatter(env, &opts.outputFormat)),
Run: func(cmd *cobra.Command, args []string) {
if opts.timeout < minTimeout {
Expand Down
23 changes: 23 additions & 0 deletions src/k8s/pkg/k8sd/api/cluster.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package api

import (
"context"
"database/sql"
"fmt"
"net/http"

apiv1 "github.com/canonical/k8s/api/v1"
"github.com/canonical/k8s/pkg/k8sd/api/impl"
"github.com/canonical/k8s/pkg/k8sd/database"
databaseutil "github.com/canonical/k8s/pkg/k8sd/database/util"
"github.com/canonical/k8s/pkg/k8sd/types"
"github.com/canonical/lxd/lxd/response"
"github.com/canonical/microcluster/state"
)
Expand Down Expand Up @@ -36,6 +40,18 @@ func (e *Endpoints) getClusterStatus(s *state.State, r *http.Request) response.R
return response.InternalError(fmt.Errorf("failed to check if cluster has ready nodes: %w", err))
}

var statuses map[string]types.FeatureStatus
if err := s.Database.Transaction(s.Context, func(ctx context.Context, tx *sql.Tx) error {
var err error
statuses, err = database.GetFeatureStatuses(s.Context, tx)
if err != nil {
return fmt.Errorf("failed to get feature statuses: %w", err)
}
return nil
}); err != nil {
return response.InternalError(fmt.Errorf("database transaction failed: %w", err))
}

result := apiv1.GetClusterStatusResponse{
ClusterStatus: apiv1.ClusterStatus{
Ready: ready,
Expand All @@ -45,6 +61,13 @@ func (e *Endpoints) getClusterStatus(s *state.State, r *http.Request) response.R
Type: config.Datastore.GetType(),
Servers: config.Datastore.GetExternalServers(),
},
DNS: statuses["dns"].ToAPI(),
Network: statuses["network"].ToAPI(),
LoadBalancer: statuses["load-balancer"].ToAPI(),
Ingress: statuses["ingress"].ToAPI(),
Gateway: statuses["gateway"].ToAPI(),
MetricsServer: statuses["metrics-server"].ToAPI(),
LocalStorage: statuses["local-storage"].ToAPI(),
},
}

Expand Down
Loading
Loading