diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go index 11b2c708c2..d6b43a9b47 100644 --- a/pkg/handlers/handlers.go +++ b/pkg/handlers/handlers.go @@ -259,22 +259,35 @@ func RegisterSessionAuthRoutes(r *mux.Router, kotsStore store.Store, handler KOT HandlerFunc(middleware.EnforceAccess(policy.BackupRead, handler.GetVeleroStatus)) // KURL - r.Name("Kurl").Path("/api/v1/kurl").HandlerFunc(NotImplemented) // I'm not sure why this is here - r.Name("GenerateNodeJoinCommandWorker").Path("/api/v1/kurl/generate-node-join-command-worker").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateNodeJoinCommandWorker)) - r.Name("GenerateNodeJoinCommandMaster").Path("/api/v1/kurl/generate-node-join-command-master").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateNodeJoinCommandMaster)) - r.Name("GenerateNodeJoinCommandSecondary").Path("/api/v1/kurl/generate-node-join-command-secondary").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateNodeJoinCommandSecondary)) - r.Name("GenerateNodeJoinCommandPrimary").Path("/api/v1/kurl/generate-node-join-command-primary").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateNodeJoinCommandPrimary)) - r.Name("DrainNode").Path("/api/v1/kurl/nodes/{nodeName}/drain").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DrainNode)) - r.Name("DeleteNode").Path("/api/v1/kurl/nodes/{nodeName}").Methods("DELETE"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DeleteNode)) + r.Name("Kurl").Path("/api/v1/kurl").HandlerFunc(NotImplemented) + r.Name("GenerateKurlNodeJoinCommandWorker").Path("/api/v1/kurl/generate-node-join-command-worker").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateKurlNodeJoinCommandWorker)) + r.Name("GenerateKurlNodeJoinCommandMaster").Path("/api/v1/kurl/generate-node-join-command-master").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateKurlNodeJoinCommandMaster)) + r.Name("GenerateKurlNodeJoinCommandSecondary").Path("/api/v1/kurl/generate-node-join-command-secondary").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateKurlNodeJoinCommandSecondary)) + r.Name("GenerateKurlNodeJoinCommandPrimary").Path("/api/v1/kurl/generate-node-join-command-primary").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateKurlNodeJoinCommandPrimary)) + r.Name("DrainKurlNode").Path("/api/v1/kurl/nodes/{nodeName}/drain").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DrainKurlNode)) + r.Name("DeleteKurlNode").Path("/api/v1/kurl/nodes/{nodeName}").Methods("DELETE"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DeleteKurlNode)) r.Name("GetKurlNodes").Path("/api/v1/kurl/nodes").Methods("GET"). HandlerFunc(middleware.EnforceAccess(policy.ClusterRead, handler.GetKurlNodes)) + // HelmVM + r.Name("HelmVM").Path("/api/v1/helmvm").HandlerFunc(NotImplemented) + r.Name("GenerateHelmVMNodeJoinCommandSecondary").Path("/api/v1/helmvm/generate-node-join-command-secondary").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateHelmVMNodeJoinCommandSecondary)) + r.Name("GenerateHelmVMNodeJoinCommandPrimary").Path("/api/v1/helmvm/generate-node-join-command-primary").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateHelmVMNodeJoinCommandPrimary)) + r.Name("DrainHelmVMNode").Path("/api/v1/helmvm/nodes/{nodeName}/drain").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DrainHelmVMNode)) + r.Name("DeleteHelmVMNode").Path("/api/v1/helmvm/nodes/{nodeName}").Methods("DELETE"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DeleteHelmVMNode)) + r.Name("GetHelmVMNodes").Path("/api/v1/helmvm/nodes").Methods("GET"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterRead, handler.GetHelmVMNodes)) + // Prometheus r.Name("SetPrometheusAddress").Path("/api/v1/prometheus").Methods("POST"). HandlerFunc(middleware.EnforceAccess(policy.PrometheussettingsWrite, handler.SetPrometheusAddress)) diff --git a/pkg/handlers/handlers_test.go b/pkg/handlers/handlers_test.go index 7b15466cc9..91bd7c0731 100644 --- a/pkg/handlers/handlers_test.go +++ b/pkg/handlers/handlers_test.go @@ -1136,64 +1136,64 @@ var HandlerPolicyTests = map[string][]HandlerPolicyTest{ }, "Kurl": {}, // Not implemented - "GenerateNodeJoinCommandWorker": { + "GenerateKurlNodeJoinCommandWorker": { { Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.GenerateNodeJoinCommandWorker(gomock.Any(), gomock.Any()) + handlerRecorder.GenerateKurlNodeJoinCommandWorker(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "GenerateNodeJoinCommandMaster": { + "GenerateKurlNodeJoinCommandMaster": { { Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.GenerateNodeJoinCommandMaster(gomock.Any(), gomock.Any()) + handlerRecorder.GenerateKurlNodeJoinCommandMaster(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "GenerateNodeJoinCommandSecondary": { + "GenerateKurlNodeJoinCommandSecondary": { { Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.GenerateNodeJoinCommandSecondary(gomock.Any(), gomock.Any()) + handlerRecorder.GenerateKurlNodeJoinCommandSecondary(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "GenerateNodeJoinCommandPrimary": { + "GenerateKurlNodeJoinCommandPrimary": { { Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.GenerateNodeJoinCommandPrimary(gomock.Any(), gomock.Any()) + handlerRecorder.GenerateKurlNodeJoinCommandPrimary(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "DrainNode": { + "DrainKurlNode": { { Vars: map[string]string{"nodeName": "node-name"}, Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.DrainNode(gomock.Any(), gomock.Any()) + handlerRecorder.DrainKurlNode(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "DeleteNode": { + "DeleteKurlNode": { { Vars: map[string]string{"nodeName": "node-name"}, Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.DeleteNode(gomock.Any(), gomock.Any()) + handlerRecorder.DeleteKurlNode(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, @@ -1209,6 +1209,60 @@ var HandlerPolicyTests = map[string][]HandlerPolicyTest{ }, }, + "HelmVM": {}, // Not implemented + "GenerateHelmVMNodeJoinCommandSecondary": { + { + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.GenerateHelmVMNodeJoinCommandSecondary(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, + "GenerateHelmVMNodeJoinCommandPrimary": { + { + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.GenerateHelmVMNodeJoinCommandPrimary(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, + "DrainHelmVMNode": { + { + Vars: map[string]string{"nodeName": "node-name"}, + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.DrainHelmVMNode(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, + "DeleteHelmVMNode": { + { + Vars: map[string]string{"nodeName": "node-name"}, + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.DeleteHelmVMNode(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, + "GetHelmVMNodes": { + { + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.GetHelmVMNodes(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, + // Prometheus "SetPrometheusAddress": { { diff --git a/pkg/handlers/helmvm_delete_node.go b/pkg/handlers/helmvm_delete_node.go new file mode 100644 index 0000000000..1b732ab07f --- /dev/null +++ b/pkg/handlers/helmvm_delete_node.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" + "github.com/replicatedhq/kots/pkg/helmvm" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/logger" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (h *Handler) DeleteHelmVMNode(w http.ResponseWriter, r *http.Request) { + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + restconfig, err := k8sutil.GetClusterConfig() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + ctx := context.Background() + nodeName := mux.Vars(r)["nodeName"] + node, err := client.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + logger.Errorf("Failed to delete node %s: not found", nodeName) + w.WriteHeader(http.StatusNotFound) + return + } + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if err := helmvm.DeleteNode(ctx, client, restconfig, node); err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + logger.Infof("Node %s successfully deleted", node.Name) +} diff --git a/pkg/handlers/helmvm_drain_node.go b/pkg/handlers/helmvm_drain_node.go new file mode 100644 index 0000000000..ae0a337f6f --- /dev/null +++ b/pkg/handlers/helmvm_drain_node.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" + "github.com/replicatedhq/kots/pkg/helmvm" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/logger" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (h *Handler) DrainHelmVMNode(w http.ResponseWriter, r *http.Request) { + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + ctx := context.Background() + nodeName := mux.Vars(r)["nodeName"] + node, err := client.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + logger.Errorf("Failed to drain node %s: not found", nodeName) + w.WriteHeader(http.StatusNotFound) + return + } + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // This pod may get evicted and not be able to respond to the request + go func() { + if err := helmvm.DrainNode(ctx, client, node); err != nil { + logger.Error(err) + return + } + logger.Infof("Node %s successfully drained", node.Name) + }() +} diff --git a/pkg/handlers/helmvm_get.go b/pkg/handlers/helmvm_get.go new file mode 100644 index 0000000000..cd440d116f --- /dev/null +++ b/pkg/handlers/helmvm_get.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + + "github.com/replicatedhq/kots/pkg/helmvm" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/logger" +) + +func (h *Handler) GetHelmVMNodes(w http.ResponseWriter, r *http.Request) { + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + nodes, err := helmvm.GetNodes(client) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + JSON(w, http.StatusOK, nodes) +} diff --git a/pkg/handlers/helmvm_node_join_command.go b/pkg/handlers/helmvm_node_join_command.go new file mode 100644 index 0000000000..6604b659d9 --- /dev/null +++ b/pkg/handlers/helmvm_node_join_command.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/replicatedhq/kots/pkg/helmvm" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/logger" +) + +type GenerateHelmVMNodeJoinCommandResponse struct { + Command []string `json:"command"` + Expiry string `json:"expiry"` +} + +func (h *Handler) GenerateHelmVMNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + command, expiry, err := helmvm.GenerateAddNodeCommand(client, false) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + JSON(w, http.StatusOK, GenerateHelmVMNodeJoinCommandResponse{ + Command: command, + Expiry: expiry.Format(time.RFC3339), + }) +} + +func (h *Handler) GenerateHelmVMNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + command, expiry, err := helmvm.GenerateAddNodeCommand(client, true) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + JSON(w, http.StatusOK, GenerateHelmVMNodeJoinCommandResponse{ + Command: command, + Expiry: expiry.Format(time.RFC3339), + }) +} diff --git a/pkg/handlers/interface.go b/pkg/handlers/interface.go index 40d7f22bdf..69b3539288 100644 --- a/pkg/handlers/interface.go +++ b/pkg/handlers/interface.go @@ -130,14 +130,21 @@ type KOTSHandler interface { GetVeleroStatus(w http.ResponseWriter, r *http.Request) // KURL - GenerateNodeJoinCommandWorker(w http.ResponseWriter, r *http.Request) - GenerateNodeJoinCommandMaster(w http.ResponseWriter, r *http.Request) - GenerateNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) - GenerateNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) - DrainNode(w http.ResponseWriter, r *http.Request) - DeleteNode(w http.ResponseWriter, r *http.Request) + GenerateKurlNodeJoinCommandWorker(w http.ResponseWriter, r *http.Request) + GenerateKurlNodeJoinCommandMaster(w http.ResponseWriter, r *http.Request) + GenerateKurlNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) + GenerateKurlNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) + DrainKurlNode(w http.ResponseWriter, r *http.Request) + DeleteKurlNode(w http.ResponseWriter, r *http.Request) GetKurlNodes(w http.ResponseWriter, r *http.Request) + // HelmVM + GenerateHelmVMNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) + GenerateHelmVMNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) + DrainHelmVMNode(w http.ResponseWriter, r *http.Request) + DeleteHelmVMNode(w http.ResponseWriter, r *http.Request) + GetHelmVMNodes(w http.ResponseWriter, r *http.Request) + // Prometheus SetPrometheusAddress(w http.ResponseWriter, r *http.Request) diff --git a/pkg/handlers/kurl_delete_node.go b/pkg/handlers/kurl_delete_node.go index 02f075d88f..1edf8ac46f 100644 --- a/pkg/handlers/kurl_delete_node.go +++ b/pkg/handlers/kurl_delete_node.go @@ -13,7 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func (h *Handler) DeleteNode(w http.ResponseWriter, r *http.Request) { +func (h *Handler) DeleteKurlNode(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) diff --git a/pkg/handlers/kurl_drain_node.go b/pkg/handlers/kurl_drain_node.go index 98809a9e7a..80e58b05d1 100644 --- a/pkg/handlers/kurl_drain_node.go +++ b/pkg/handlers/kurl_drain_node.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func (h *Handler) DrainNode(w http.ResponseWriter, r *http.Request) { +func (h *Handler) DrainKurlNode(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) diff --git a/pkg/handlers/kurl_node_join_command.go b/pkg/handlers/kurl_node_join_command.go index 6d1bdfa1b9..1f0ed4a438 100644 --- a/pkg/handlers/kurl_node_join_command.go +++ b/pkg/handlers/kurl_node_join_command.go @@ -9,12 +9,12 @@ import ( "github.com/replicatedhq/kots/pkg/logger" ) -type GenerateNodeJoinCommandResponse struct { +type GenerateKurlNodeJoinCommandResponse struct { Command []string `json:"command"` Expiry string `json:"expiry"` } -func (h *Handler) GenerateNodeJoinCommandWorker(w http.ResponseWriter, r *http.Request) { +func (h *Handler) GenerateKurlNodeJoinCommandWorker(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) @@ -28,13 +28,13 @@ func (h *Handler) GenerateNodeJoinCommandWorker(w http.ResponseWriter, r *http.R w.WriteHeader(http.StatusInternalServerError) return } - JSON(w, http.StatusOK, GenerateNodeJoinCommandResponse{ + JSON(w, http.StatusOK, GenerateKurlNodeJoinCommandResponse{ Command: command, Expiry: expiry.Format(time.RFC3339), }) } -func (h *Handler) GenerateNodeJoinCommandMaster(w http.ResponseWriter, r *http.Request) { +func (h *Handler) GenerateKurlNodeJoinCommandMaster(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) @@ -48,13 +48,13 @@ func (h *Handler) GenerateNodeJoinCommandMaster(w http.ResponseWriter, r *http.R w.WriteHeader(http.StatusInternalServerError) return } - JSON(w, http.StatusOK, GenerateNodeJoinCommandResponse{ + JSON(w, http.StatusOK, GenerateKurlNodeJoinCommandResponse{ Command: command, Expiry: expiry.Format(time.RFC3339), }) } -func (h *Handler) GenerateNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { +func (h *Handler) GenerateKurlNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) @@ -68,13 +68,13 @@ func (h *Handler) GenerateNodeJoinCommandSecondary(w http.ResponseWriter, r *htt w.WriteHeader(http.StatusInternalServerError) return } - JSON(w, http.StatusOK, GenerateNodeJoinCommandResponse{ + JSON(w, http.StatusOK, GenerateKurlNodeJoinCommandResponse{ Command: command, Expiry: expiry.Format(time.RFC3339), }) } -func (h *Handler) GenerateNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { +func (h *Handler) GenerateKurlNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) @@ -88,7 +88,7 @@ func (h *Handler) GenerateNodeJoinCommandPrimary(w http.ResponseWriter, r *http. w.WriteHeader(http.StatusInternalServerError) return } - JSON(w, http.StatusOK, GenerateNodeJoinCommandResponse{ + JSON(w, http.StatusOK, GenerateKurlNodeJoinCommandResponse{ Command: command, Expiry: expiry.Format(time.RFC3339), }) diff --git a/pkg/handlers/metadata.go b/pkg/handlers/metadata.go index 37952c971a..81903b26dd 100644 --- a/pkg/handlers/metadata.go +++ b/pkg/handlers/metadata.go @@ -52,6 +52,7 @@ type MetadataResponseBranding struct { type AdminConsoleMetadata struct { IsAirgap bool `json:"isAirgap"` IsKurl bool `json:"isKurl"` + IsHelmVM bool `json:"isHelmVM"` } // GetMetadataHandler helper function that returns a http handler func that returns metadata. It takes a function that @@ -72,6 +73,7 @@ func GetMetadataHandler(getK8sInfoFn MetadataK8sFn, kotsStore store.Store) http. if kuberneteserrors.IsNotFound(err) { metadataResponse.AdminConsoleMetadata.IsAirgap = kotsadmMetadata.IsAirgap metadataResponse.AdminConsoleMetadata.IsKurl = kotsadmMetadata.IsKurl + metadataResponse.AdminConsoleMetadata.IsHelmVM = kotsadmMetadata.IsHelmVM logger.Info(fmt.Sprintf("config map %q not found", metadataConfigMapName)) JSON(w, http.StatusOK, &metadataResponse) @@ -114,6 +116,7 @@ func GetMetadataHandler(getK8sInfoFn MetadataK8sFn, kotsStore store.Store) http. metadataResponse.AdminConsoleMetadata = AdminConsoleMetadata{ IsAirgap: kotsadmMetadata.IsAirgap, IsKurl: kotsadmMetadata.IsKurl, + IsHelmVM: kotsadmMetadata.IsHelmVM, } JSON(w, http.StatusOK, metadataResponse) diff --git a/pkg/handlers/mock/mock.go b/pkg/handlers/mock/mock.go index 45f4d1c242..7440665850 100644 --- a/pkg/handlers/mock/mock.go +++ b/pkg/handlers/mock/mock.go @@ -262,16 +262,28 @@ func (mr *MockKOTSHandlerMockRecorder) DeleteBackup(w, r interface{}) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBackup", reflect.TypeOf((*MockKOTSHandler)(nil).DeleteBackup), w, r) } -// DeleteNode mocks base method. -func (m *MockKOTSHandler) DeleteNode(w http.ResponseWriter, r *http.Request) { +// DeleteHelmVMNode mocks base method. +func (m *MockKOTSHandler) DeleteHelmVMNode(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "DeleteNode", w, r) + m.ctrl.Call(m, "DeleteHelmVMNode", w, r) } -// DeleteNode indicates an expected call of DeleteNode. -func (mr *MockKOTSHandlerMockRecorder) DeleteNode(w, r interface{}) *gomock.Call { +// DeleteHelmVMNode indicates an expected call of DeleteHelmVMNode. +func (mr *MockKOTSHandlerMockRecorder) DeleteHelmVMNode(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNode", reflect.TypeOf((*MockKOTSHandler)(nil).DeleteNode), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHelmVMNode", reflect.TypeOf((*MockKOTSHandler)(nil).DeleteHelmVMNode), w, r) +} + +// DeleteKurlNode mocks base method. +func (m *MockKOTSHandler) DeleteKurlNode(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "DeleteKurlNode", w, r) +} + +// DeleteKurlNode indicates an expected call of DeleteKurlNode. +func (mr *MockKOTSHandlerMockRecorder) DeleteKurlNode(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteKurlNode", reflect.TypeOf((*MockKOTSHandler)(nil).DeleteKurlNode), w, r) } // DeleteRedact mocks base method. @@ -382,16 +394,28 @@ func (mr *MockKOTSHandlerMockRecorder) DownloadSupportBundle(w, r interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadSupportBundle", reflect.TypeOf((*MockKOTSHandler)(nil).DownloadSupportBundle), w, r) } -// DrainNode mocks base method. -func (m *MockKOTSHandler) DrainNode(w http.ResponseWriter, r *http.Request) { +// DrainHelmVMNode mocks base method. +func (m *MockKOTSHandler) DrainHelmVMNode(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "DrainNode", w, r) + m.ctrl.Call(m, "DrainHelmVMNode", w, r) } -// DrainNode indicates an expected call of DrainNode. -func (mr *MockKOTSHandlerMockRecorder) DrainNode(w, r interface{}) *gomock.Call { +// DrainHelmVMNode indicates an expected call of DrainHelmVMNode. +func (mr *MockKOTSHandlerMockRecorder) DrainHelmVMNode(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DrainNode", reflect.TypeOf((*MockKOTSHandler)(nil).DrainNode), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DrainHelmVMNode", reflect.TypeOf((*MockKOTSHandler)(nil).DrainHelmVMNode), w, r) +} + +// DrainKurlNode mocks base method. +func (m *MockKOTSHandler) DrainKurlNode(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "DrainKurlNode", w, r) +} + +// DrainKurlNode indicates an expected call of DrainKurlNode. +func (mr *MockKOTSHandlerMockRecorder) DrainKurlNode(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DrainKurlNode", reflect.TypeOf((*MockKOTSHandler)(nil).DrainKurlNode), w, r) } // ExchangePlatformLicense mocks base method. @@ -418,52 +442,76 @@ func (mr *MockKOTSHandlerMockRecorder) GarbageCollectImages(w, r interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GarbageCollectImages", reflect.TypeOf((*MockKOTSHandler)(nil).GarbageCollectImages), w, r) } -// GenerateNodeJoinCommandMaster mocks base method. -func (m *MockKOTSHandler) GenerateNodeJoinCommandMaster(w http.ResponseWriter, r *http.Request) { +// GenerateHelmVMNodeJoinCommandPrimary mocks base method. +func (m *MockKOTSHandler) GenerateHelmVMNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "GenerateHelmVMNodeJoinCommandPrimary", w, r) +} + +// GenerateHelmVMNodeJoinCommandPrimary indicates an expected call of GenerateHelmVMNodeJoinCommandPrimary. +func (mr *MockKOTSHandlerMockRecorder) GenerateHelmVMNodeJoinCommandPrimary(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateHelmVMNodeJoinCommandPrimary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateHelmVMNodeJoinCommandPrimary), w, r) +} + +// GenerateHelmVMNodeJoinCommandSecondary mocks base method. +func (m *MockKOTSHandler) GenerateHelmVMNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "GenerateNodeJoinCommandMaster", w, r) + m.ctrl.Call(m, "GenerateHelmVMNodeJoinCommandSecondary", w, r) } -// GenerateNodeJoinCommandMaster indicates an expected call of GenerateNodeJoinCommandMaster. -func (mr *MockKOTSHandlerMockRecorder) GenerateNodeJoinCommandMaster(w, r interface{}) *gomock.Call { +// GenerateHelmVMNodeJoinCommandSecondary indicates an expected call of GenerateHelmVMNodeJoinCommandSecondary. +func (mr *MockKOTSHandlerMockRecorder) GenerateHelmVMNodeJoinCommandSecondary(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateNodeJoinCommandMaster", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateNodeJoinCommandMaster), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateHelmVMNodeJoinCommandSecondary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateHelmVMNodeJoinCommandSecondary), w, r) } -// GenerateNodeJoinCommandPrimary mocks base method. -func (m *MockKOTSHandler) GenerateNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { +// GenerateKurlNodeJoinCommandMaster mocks base method. +func (m *MockKOTSHandler) GenerateKurlNodeJoinCommandMaster(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "GenerateNodeJoinCommandPrimary", w, r) + m.ctrl.Call(m, "GenerateKurlNodeJoinCommandMaster", w, r) } -// GenerateNodeJoinCommandPrimary indicates an expected call of GenerateNodeJoinCommandPrimary. -func (mr *MockKOTSHandlerMockRecorder) GenerateNodeJoinCommandPrimary(w, r interface{}) *gomock.Call { +// GenerateKurlNodeJoinCommandMaster indicates an expected call of GenerateKurlNodeJoinCommandMaster. +func (mr *MockKOTSHandlerMockRecorder) GenerateKurlNodeJoinCommandMaster(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateNodeJoinCommandPrimary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateNodeJoinCommandPrimary), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateKurlNodeJoinCommandMaster", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateKurlNodeJoinCommandMaster), w, r) } -// GenerateNodeJoinCommandSecondary mocks base method. -func (m *MockKOTSHandler) GenerateNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { +// GenerateKurlNodeJoinCommandPrimary mocks base method. +func (m *MockKOTSHandler) GenerateKurlNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "GenerateNodeJoinCommandSecondary", w, r) + m.ctrl.Call(m, "GenerateKurlNodeJoinCommandPrimary", w, r) } -// GenerateNodeJoinCommandSecondary indicates an expected call of GenerateNodeJoinCommandSecondary. -func (mr *MockKOTSHandlerMockRecorder) GenerateNodeJoinCommandSecondary(w, r interface{}) *gomock.Call { +// GenerateKurlNodeJoinCommandPrimary indicates an expected call of GenerateKurlNodeJoinCommandPrimary. +func (mr *MockKOTSHandlerMockRecorder) GenerateKurlNodeJoinCommandPrimary(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateNodeJoinCommandSecondary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateNodeJoinCommandSecondary), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateKurlNodeJoinCommandPrimary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateKurlNodeJoinCommandPrimary), w, r) } -// GenerateNodeJoinCommandWorker mocks base method. -func (m *MockKOTSHandler) GenerateNodeJoinCommandWorker(w http.ResponseWriter, r *http.Request) { +// GenerateKurlNodeJoinCommandSecondary mocks base method. +func (m *MockKOTSHandler) GenerateKurlNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "GenerateNodeJoinCommandWorker", w, r) + m.ctrl.Call(m, "GenerateKurlNodeJoinCommandSecondary", w, r) } -// GenerateNodeJoinCommandWorker indicates an expected call of GenerateNodeJoinCommandWorker. -func (mr *MockKOTSHandlerMockRecorder) GenerateNodeJoinCommandWorker(w, r interface{}) *gomock.Call { +// GenerateKurlNodeJoinCommandSecondary indicates an expected call of GenerateKurlNodeJoinCommandSecondary. +func (mr *MockKOTSHandlerMockRecorder) GenerateKurlNodeJoinCommandSecondary(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateNodeJoinCommandWorker", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateNodeJoinCommandWorker), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateKurlNodeJoinCommandSecondary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateKurlNodeJoinCommandSecondary), w, r) +} + +// GenerateKurlNodeJoinCommandWorker mocks base method. +func (m *MockKOTSHandler) GenerateKurlNodeJoinCommandWorker(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "GenerateKurlNodeJoinCommandWorker", w, r) +} + +// GenerateKurlNodeJoinCommandWorker indicates an expected call of GenerateKurlNodeJoinCommandWorker. +func (mr *MockKOTSHandlerMockRecorder) GenerateKurlNodeJoinCommandWorker(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateKurlNodeJoinCommandWorker", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateKurlNodeJoinCommandWorker), w, r) } // GetAdminConsoleUpdateStatus mocks base method. @@ -706,6 +754,18 @@ func (mr *MockKOTSHandlerMockRecorder) GetGlobalSnapshotSettings(w, r interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGlobalSnapshotSettings", reflect.TypeOf((*MockKOTSHandler)(nil).GetGlobalSnapshotSettings), w, r) } +// GetHelmVMNodes mocks base method. +func (m *MockKOTSHandler) GetHelmVMNodes(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "GetHelmVMNodes", w, r) +} + +// GetHelmVMNodes indicates an expected call of GetHelmVMNodes. +func (mr *MockKOTSHandlerMockRecorder) GetHelmVMNodes(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHelmVMNodes", reflect.TypeOf((*MockKOTSHandler)(nil).GetHelmVMNodes), w, r) +} + // GetIdentityServiceConfig mocks base method. func (m *MockKOTSHandler) GetIdentityServiceConfig(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() diff --git a/pkg/helmvm/delete_node.go b/pkg/helmvm/delete_node.go new file mode 100644 index 0000000000..24d7e5e46d --- /dev/null +++ b/pkg/helmvm/delete_node.go @@ -0,0 +1,13 @@ +package helmvm + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func DeleteNode(ctx context.Context, client kubernetes.Interface, restconfig *rest.Config, node *corev1.Node) error { + return nil +} diff --git a/pkg/helmvm/drain_node.go b/pkg/helmvm/drain_node.go new file mode 100644 index 0000000000..b8fa55afbb --- /dev/null +++ b/pkg/helmvm/drain_node.go @@ -0,0 +1,12 @@ +package helmvm + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" +) + +func DrainNode(ctx context.Context, client kubernetes.Interface, node *corev1.Node) error { + return nil +} diff --git a/pkg/helmvm/exec.go b/pkg/helmvm/exec.go new file mode 100644 index 0000000000..04f94635de --- /dev/null +++ b/pkg/helmvm/exec.go @@ -0,0 +1,11 @@ +package helmvm + +import ( + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/rest" +) + +// SyncExec returns exitcode, stdout, stderr. A non-zero exit code from the command is not considered an error. +func SyncExec(coreClient corev1client.CoreV1Interface, clientConfig *rest.Config, ns, pod, container string, command ...string) (int, string, string, error) { + return 0, "", "", nil +} diff --git a/pkg/helmvm/helmvm_nodes.go b/pkg/helmvm/helmvm_nodes.go new file mode 100644 index 0000000000..e00dca2108 --- /dev/null +++ b/pkg/helmvm/helmvm_nodes.go @@ -0,0 +1,212 @@ +package helmvm + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "os" + "strconv" + "time" + + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/helmvm/types" + "github.com/replicatedhq/kots/pkg/logger" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + statsv1alpha1 "k8s.io/kubelet/pkg/apis/stats/v1alpha1" +) + +// GetNodes will get a list of nodes with stats +func GetNodes(client kubernetes.Interface) (*types.HelmVMNodes, error) { + nodes, err := client.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, errors.Wrap(err, "list nodes") + } + + toReturn := types.HelmVMNodes{} + + for _, node := range nodes.Items { + cpuCapacity := types.CapacityAvailable{} + memoryCapacity := types.CapacityAvailable{} + podCapacity := types.CapacityAvailable{} + + memoryCapacity.Capacity = float64(node.Status.Capacity.Memory().Value()) / math.Pow(2, 30) // capacity in GB + + cpuCapacity.Capacity, err = strconv.ParseFloat(node.Status.Capacity.Cpu().String(), 64) + if err != nil { + return nil, errors.Wrapf(err, "parse CPU capacity %q for node %s", node.Status.Capacity.Cpu().String(), node.Name) + } + + podCapacity.Capacity = float64(node.Status.Capacity.Pods().Value()) + + nodeIP := "" + for _, address := range node.Status.Addresses { + if address.Type == corev1.NodeInternalIP { + nodeIP = address.Address + } + } + + if nodeIP == "" { + logger.Infof("Did not find address for node %s, %+v", node.Name, node.Status.Addresses) + } else { + nodeMetrics, err := getNodeMetrics(nodeIP) + if err != nil { + logger.Infof("Got error retrieving stats for node %q: %v", node.Name, err) + } else { + if nodeMetrics.Node.Memory != nil && nodeMetrics.Node.Memory.AvailableBytes != nil { + memoryCapacity.Available = float64(*nodeMetrics.Node.Memory.AvailableBytes) / math.Pow(2, 30) + } + + if nodeMetrics.Node.CPU != nil && nodeMetrics.Node.CPU.UsageNanoCores != nil { + cpuCapacity.Available = cpuCapacity.Capacity - (float64(*nodeMetrics.Node.CPU.UsageNanoCores) / math.Pow(10, 9)) + } + + podCapacity.Available = podCapacity.Capacity - float64(len(nodeMetrics.Pods)) + } + } + + nodeLabelArray := []string{} + for k, v := range node.Labels { + nodeLabelArray = append(nodeLabelArray, fmt.Sprintf("%s:%s", k, v)) + } + + toReturn.Nodes = append(toReturn.Nodes, types.Node{ + Name: node.Name, + IsConnected: isConnected(node), + IsReady: isReady(node), + IsPrimaryNode: isPrimary(node), + CanDelete: node.Spec.Unschedulable && !isConnected(node), + KubeletVersion: node.Status.NodeInfo.KubeletVersion, + CPU: cpuCapacity, + Memory: memoryCapacity, + Pods: podCapacity, + Labels: nodeLabelArray, + Conditions: findNodeConditions(node.Status.Conditions), + }) + } + + isHelmVM, err := IsHelmVM(client) + if err != nil { + return nil, errors.Wrap(err, "is helmvm") + } + toReturn.IsHelmVMEnabled = isHelmVM + + isHA, err := IsHA(client) + if err != nil { + return nil, errors.Wrap(err, "is ha") + } + toReturn.HA = isHA + + return &toReturn, nil +} + +func findNodeConditions(conditions []corev1.NodeCondition) types.NodeConditions { + discoveredConditions := types.NodeConditions{} + for _, condition := range conditions { + if condition.Type == "MemoryPressure" { + discoveredConditions.MemoryPressure = condition.Status == corev1.ConditionTrue + } + if condition.Type == "DiskPressure" { + discoveredConditions.DiskPressure = condition.Status == corev1.ConditionTrue + } + if condition.Type == "PIDPressure" { + discoveredConditions.PidPressure = condition.Status == corev1.ConditionTrue + } + if condition.Type == "Ready" { + discoveredConditions.Ready = condition.Status == corev1.ConditionTrue + } + } + return discoveredConditions +} + +// get kubelet PKI info from /etc/kubernetes/pki/kubelet, use it to hit metrics server at `http://${nodeIP}:10255/stats/summary` +func getNodeMetrics(nodeIP string) (*statsv1alpha1.Summary, error) { + client := http.Client{ + Timeout: time.Second, + } + port := 10255 + + // only use mutual TLS if client cert exists + _, err := os.ReadFile("/etc/kubernetes/pki/kubelet/client.crt") + if err == nil { + cert, err := tls.LoadX509KeyPair("/etc/kubernetes/pki/kubelet/client.crt", "/etc/kubernetes/pki/kubelet/client.key") + if err != nil { + return nil, errors.Wrap(err, "get client keypair") + } + + // this will leak memory + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{cert}, + InsecureSkipVerify: true, + }, + } + port = 10250 + } + + r, err := client.Get(fmt.Sprintf("https://%s:%d/stats/summary", nodeIP, port)) + if err != nil { + return nil, errors.Wrapf(err, "get node %s stats", nodeIP) + } + defer r.Body.Close() + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, errors.Wrapf(err, "read node %s stats response", nodeIP) + } + + summary := statsv1alpha1.Summary{} + err = json.Unmarshal(body, &summary) + if err != nil { + return nil, errors.Wrapf(err, "parse node %s stats response", nodeIP) + } + + return &summary, nil +} + +func isConnected(node corev1.Node) bool { + for _, taint := range node.Spec.Taints { + if taint.Key == "node.kubernetes.io/unreachable" { + return false + } + } + + return true +} + +func isReady(node corev1.Node) bool { + for _, condition := range node.Status.Conditions { + if condition.Type == "Ready" { + return condition.Status == corev1.ConditionTrue + } + } + + return false +} + +func isPrimary(node corev1.Node) bool { + for label := range node.ObjectMeta.Labels { + if label == "node-role.kubernetes.io/master" { + return true + } + if label == "node-role.kubernetes.io/control-plane" { + return true + } + } + + return false +} + +func internalIP(node corev1.Node) string { + for _, address := range node.Status.Addresses { + if address.Type == corev1.NodeInternalIP { + return address.Address + } + } + return "" +} diff --git a/pkg/helmvm/node_join.go b/pkg/helmvm/node_join.go new file mode 100644 index 0000000000..6aad6255a9 --- /dev/null +++ b/pkg/helmvm/node_join.go @@ -0,0 +1,12 @@ +package helmvm + +import ( + "time" + + "k8s.io/client-go/kubernetes" +) + +// GenerateAddNodeCommand will generate the HelmVM node add command for a primary or secondary node +func GenerateAddNodeCommand(client kubernetes.Interface, primary bool) ([]string, *time.Time, error) { + return nil, nil, nil +} diff --git a/pkg/helmvm/types/types.go b/pkg/helmvm/types/types.go new file mode 100644 index 0000000000..c298dfbd93 --- /dev/null +++ b/pkg/helmvm/types/types.go @@ -0,0 +1,33 @@ +package types + +type HelmVMNodes struct { + Nodes []Node `json:"nodes"` + HA bool `json:"ha"` + IsHelmVMEnabled bool `json:"isHelmVMEnabled"` +} + +type Node struct { + Name string `json:"name"` + IsConnected bool `json:"isConnected"` + IsReady bool `json:"isReady"` + IsPrimaryNode bool `json:"isPrimaryNode"` + CanDelete bool `json:"canDelete"` + KubeletVersion string `json:"kubeletVersion"` + CPU CapacityAvailable `json:"cpu"` + Memory CapacityAvailable `json:"memory"` + Pods CapacityAvailable `json:"pods"` + Labels []string `json:"labels"` + Conditions NodeConditions `json:"conditions"` +} + +type CapacityAvailable struct { + Capacity float64 `json:"capacity"` + Available float64 `json:"available"` +} + +type NodeConditions struct { + MemoryPressure bool `json:"memoryPressure"` + DiskPressure bool `json:"diskPressure"` + PidPressure bool `json:"pidPressure"` + Ready bool `json:"ready"` +} diff --git a/pkg/helmvm/util.go b/pkg/helmvm/util.go new file mode 100644 index 0000000000..7d2817f93e --- /dev/null +++ b/pkg/helmvm/util.go @@ -0,0 +1,13 @@ +package helmvm + +import ( + "k8s.io/client-go/kubernetes" +) + +func IsHelmVM(clientset kubernetes.Interface) (bool, error) { + return false, nil +} + +func IsHA(clientset kubernetes.Interface) (bool, error) { + return false, nil +} diff --git a/pkg/kotsadm/metadata.go b/pkg/kotsadm/metadata.go index 0e93bf2c38..9c9b045cb0 100644 --- a/pkg/kotsadm/metadata.go +++ b/pkg/kotsadm/metadata.go @@ -1,6 +1,7 @@ package kotsadm import ( + "github.com/replicatedhq/kots/pkg/helmvm" "github.com/replicatedhq/kots/pkg/kotsadm/types" "github.com/replicatedhq/kots/pkg/kurl" "k8s.io/client-go/kubernetes" @@ -8,9 +9,12 @@ import ( func GetMetadata(clientset kubernetes.Interface) types.Metadata { isKurl, _ := kurl.IsKurl(clientset) + isHelmVM, _ := helmvm.IsHelmVM(clientset) + metadata := types.Metadata{ IsAirgap: IsAirgap(), IsKurl: isKurl, + IsHelmVM: isHelmVM, } return metadata diff --git a/pkg/kotsadm/types/metadata.go b/pkg/kotsadm/types/metadata.go index 9e2586ec15..79e8b142a6 100644 --- a/pkg/kotsadm/types/metadata.go +++ b/pkg/kotsadm/types/metadata.go @@ -3,4 +3,5 @@ package types type Metadata struct { IsAirgap bool IsKurl bool + IsHelmVM bool } diff --git a/web/src/Root.tsx b/web/src/Root.tsx index d21a4ad89e..2e7930a438 100644 --- a/web/src/Root.tsx +++ b/web/src/Root.tsx @@ -9,7 +9,8 @@ import GitOps from "./components/clusters/GitOps"; import PreflightResultPage from "./components/PreflightResultPage"; import AppConfig from "./features/AppConfig/components/AppConfig"; import { AppDetailPage } from "./components/apps/AppDetailPage"; -import ClusterNodes from "./components/apps/ClusterNodes"; +import KurlClusterManagement from "./components/apps/KurlClusterManagement"; +import HelmVMClusterManagement from "./components/apps/HelmVMClusterManagement"; import UnsupportedBrowser from "./components/static/UnsupportedBrowser"; import NotFound from "./components/static/NotFound"; import { Utilities, parseUpstreamUri } from "./utilities/utilities"; @@ -465,6 +466,7 @@ const Root = () => { refetchAppsList={getAppsList} fetchingMetadata={state.fetchingMetadata} isKurlEnabled={Boolean(state.adminConsoleMetadata?.isKurl)} + isHelmVMEnabled={Boolean(state.adminConsoleMetadata?.isHelmVM)} isGitOpsSupported={isGitOpsSupported()} isIdentityServiceSupported={isIdentityServiceSupported()} appsList={state.appsList} @@ -573,7 +575,13 @@ const Root = () => { } /> } + element={ + state.adminConsoleMetadata?.isKurl ? ( + + ) : ( + + ) + } /> { + try { + const res = await fetch(`${process.env.API_ENDPOINT}/helmvm/nodes`, { + headers: { + Accept: "application/json", + }, + credentials: "include", + method: "GET", + }); + if (!res.ok) { + if (res.status === 401) { + Utilities.logoutUser(); + return; + } + console.log( + "failed to get node status list, unexpected status code", + res.status + ); + return; + } + const response = await res.json(); + this.setState({ + helmvm: response, + // if cluster doesn't support ha, then primary will be disabled. Force into secondary + selectedNodeType: !response.ha + ? "secondary" + : this.state.selectedNodeType, + }); + return response; + } catch (err) { + console.log(err); + throw err; + } + }; + + deleteNode = (name) => { + this.setState({ + confirmDeleteNode: name, + }); + }; + + cancelDeleteNode = () => { + this.setState({ + confirmDeleteNode: "", + }); + }; + + reallyDeleteNode = () => { + const name = this.state.confirmDeleteNode; + this.cancelDeleteNode(); + + fetch(`${process.env.API_ENDPOINT}/helmvm/nodes/${name}`, { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "DELETE", + }) + .then(async (res) => { + if (!res.ok) { + if (res.status === 401) { + Utilities.logoutUser(); + return; + } + this.setState({ + deleteNodeError: `Delete failed with status ${res.status}`, + }); + } + }) + .catch((err) => { + console.log(err); + }); + }; + + generateWorkerAddNodeCommand = async () => { + this.setState({ + generating: true, + command: "", + expiry: null, + generateCommandErrMsg: "", + }); + + fetch( + `${process.env.API_ENDPOINT}/helmvm/generate-node-join-command-secondary`, + { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "POST", + } + ) + .then(async (res) => { + if (!res.ok) { + this.setState({ + generating: false, + generateCommandErrMsg: `Failed to generate command with status ${res.status}`, + }); + } else { + const data = await res.json(); + this.setState({ + generating: false, + command: data.command, + expiry: data.expiry, + }); + } + }) + .catch((err) => { + console.log(err); + this.setState({ + generating: false, + generateCommandErrMsg: err ? err.message : "Something went wrong", + }); + }); + }; + + onDrainNodeClick = (name) => { + this.setState({ + showConfirmDrainModal: true, + nodeNameToDrain: name, + }); + }; + + drainNode = async (name) => { + this.setState({ showConfirmDrainModal: false, drainingNodeName: name }); + fetch(`${process.env.API_ENDPOINT}/helmvm/nodes/${name}/drain`, { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "POST", + }) + .then(async (res) => { + this.setState({ drainNodeSuccessful: true }); + setTimeout(() => { + this.setState({ + drainingNodeName: null, + drainNodeSuccessful: false, + }); + }, 3000); + }) + .catch((err) => { + console.log(err); + this.setState({ + drainingNodeName: null, + drainNodeSuccessful: false, + }); + }); + }; + + generatePrimaryAddNodeCommand = async () => { + this.setState({ + generating: true, + command: "", + expiry: null, + generateCommandErrMsg: "", + }); + + fetch( + `${process.env.API_ENDPOINT}/helmvm/generate-node-join-command-primary`, + { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "POST", + } + ) + .then(async (res) => { + if (!res.ok) { + this.setState({ + generating: false, + generateCommandErrMsg: `Failed to generate command with status ${res.status}`, + }); + } else { + const data = await res.json(); + this.setState({ + generating: false, + command: data.command, + expiry: data.expiry, + }); + } + }) + .catch((err) => { + console.log(err); + this.setState({ + generating: false, + generateCommandErrMsg: err ? err.message : "Something went wrong", + }); + }); + }; + + onAddNodeClick = () => { + this.setState( + { + displayAddNode: true, + }, + async () => { + await this.generateWorkerAddNodeCommand(); + } + ); + }; + + onSelectNodeType = (event) => { + const value = event.currentTarget.value; + this.setState( + { + selectedNodeType: value, + }, + async () => { + if (this.state.selectedNodeType === "secondary") { + await this.generateWorkerAddNodeCommand(); + } else { + await this.generatePrimaryAddNodeCommand(); + } + } + ); + }; + + ackDeleteNodeError = () => { + this.setState({ deleteNodeError: "" }); + }; + + render() { + const { helmvm } = this.state; + const { displayAddNode, generateCommandErrMsg } = this.state; + + if (!helmvm) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+
+
+

+ Your nodes +

+
+ {helmvm?.nodes && + helmvm?.nodes.map((node, i) => ( + + ))} +
+
+ {helmvm?.isHelmVMEnabled && + Utilities.sessionRolesHasOneOf([rbacRoles.CLUSTER_ADMIN]) ? ( + !displayAddNode ? ( +
+ +
+ ) : ( +
+
+

+ Add a Node +

+
+
+
+ + +
+
+ + +
+
+ {this.state.generating && ( +
+ +
+ )} + {!this.state.generating && this.state.command?.length > 0 ? ( + +

+ Run this command on the node you wish to join the + cluster +

+ + Command has been copied to your clipboard + + } + > + {[this.state.command.join(" \\\n ")]} + + {this.state.expiry && ( + + {`Expires on ${dayjs(this.state.expiry).format( + "MMM Do YYYY, h:mm:ss a z" + )} UTC${(-1 * new Date().getTimezoneOffset()) / 60}`} + + )} +
+ ) : ( + + {generateCommandErrMsg && ( +
+ + {generateCommandErrMsg} + +
+ )} +
+ )} +
+ ) + ) : null} +
+
+ {this.state.deleteNodeError && ( + + )} + +
+

+ Deleting this node may cause data loss. Are you sure you want to + proceed? +

+
+ + +
+
+
+ {this.state.showConfirmDrainModal && ( + + this.setState({ + showConfirmDrainModal: false, + nodeNameToDrain: "", + }) + } + shouldReturnFocusAfterClose={false} + contentLabel="Confirm Drain Node" + ariaHideApp={false} + className="Modal MediumSize" + > +
+

+ Are you sure you want to drain {this.state.nodeNameToDrain}? +

+

+ Draining this node may cause data loss. If you want to delete{" "} + {this.state.nodeNameToDrain} you must disconnect it after it has + been drained. +

+
+ + +
+
+
+ )} +
+ ); + } +} + +export default HelmVMClusterManagement; diff --git a/web/src/components/apps/HelmVMNodeRow.jsx b/web/src/components/apps/HelmVMNodeRow.jsx new file mode 100644 index 0000000000..93f2c5489c --- /dev/null +++ b/web/src/components/apps/HelmVMNodeRow.jsx @@ -0,0 +1,278 @@ +import React from "react"; +import classNames from "classnames"; +import Loader from "../shared/Loader"; +import { rbacRoles } from "../../constants/rbac"; +import { getPercentageStatus, Utilities } from "../../utilities/utilities"; +import Icon from "../Icon"; + +export default function HelmVMNodeRow(props) { + const { node } = props; + + const DrainDeleteNode = () => { + const { drainNode, drainNodeSuccessful, drainingNodeName } = props; + if (drainNode && Utilities.sessionRolesHasOneOf(rbacRoles.DRAIN_NODE)) { + if ( + !drainNodeSuccessful && + drainingNodeName && + drainingNodeName === node?.name + ) { + return ( +
+ + + + + Draining Node + +
+ ); + } else if (drainNodeSuccessful && drainingNodeName === node?.name) { + return ( +
+ + + Node successfully drained + +
+ ); + } else { + return ( +
+ +
+ ); + } + } + }; + + return ( +
+
+
+

+ {node?.name} +

+ {node?.isPrimaryNode && ( + + Primary node + + )} +
+
+
+

+ + {node?.isConnected ? "Connected" : "Disconnected"} +

+

+   +

+
+
+

+ + {node?.pods?.available === -1 + ? `${node?.pods?.capacity} pods` + : `${ + node?.pods?.available === 0 + ? "0" + : node?.pods?.capacity - node?.pods?.available + } pods used`} +

+ {node?.pods?.available !== -1 && ( +

+ of {node?.pods?.capacity} pods total +

+ )} +
+
+

+ + {node?.cpu?.available === -1 + ? `${node?.cpu?.capacity} ${ + node?.cpu?.available === "1" ? "core" : "cores" + }` + : `${ + node?.cpu?.available === 0 + ? "0" + : (node?.cpu?.capacity - node?.cpu?.available).toFixed(1) + } ${ + node?.cpu?.available === "1" ? "core used" : "cores used" + }`} +

+ {node?.pods?.available !== -1 && ( +

+ of {node?.cpu?.capacity}{" "} + {node?.cpu?.available === "1" ? "core total" : "cores total"} +

+ )} +
+
+

+ + {node?.memory?.available === -1 + ? `${node?.memory?.capacity?.toFixed(1)} GB` + : `${ + node?.memory?.available === 0 + ? "0" + : ( + node?.memory?.capacity - node?.memory?.available + ).toFixed(1) + } GB used`} +

+ {node?.pods?.available !== -1 && ( +

+ of {node?.memory?.capacity?.toFixed(1)} GB total +

+ )} +
+
+
+
+

+ + {node?.kubeletVersion} +

+
+
+

+ + {node?.conditions?.diskPressure + ? "No Space on Device" + : "No Disk Pressure"} +

+
+
+

+ + {node?.conditions?.pidPressure + ? "Pressure on CPU" + : "No CPU Pressure"} +

+
+
+

+ + {node?.conditions?.memoryPressure + ? "No Space on Memory" + : "No Memory Pressure"} +

+
+
+ {/* LABELS */} +
+ {node?.labels.length > 0 + ? node.labels.sort().map((label, i) => { + let labelToShow = label.replace(":", "="); + return ( +
+ {labelToShow} +
+ ); + }) + : null} +
+
+

+ For more details run{" "} + + kubectl describe node {node?.name} + +

+
+
+ +
+ ); +} diff --git a/web/src/components/apps/HelmVMNodeRow.test.js b/web/src/components/apps/HelmVMNodeRow.test.js new file mode 100644 index 0000000000..b7a4d8324c --- /dev/null +++ b/web/src/components/apps/HelmVMNodeRow.test.js @@ -0,0 +1,3 @@ +describe("HelmVMNodeRow", () => { + it.todo("upgrade to react 18 and add unit tests"); +}); diff --git a/web/src/components/apps/ClusterNodes.jsx b/web/src/components/apps/KurlClusterManagement.jsx similarity index 98% rename from web/src/components/apps/ClusterNodes.jsx rename to web/src/components/apps/KurlClusterManagement.jsx index d5307df3e0..46f6ade1fd 100644 --- a/web/src/components/apps/ClusterNodes.jsx +++ b/web/src/components/apps/KurlClusterManagement.jsx @@ -3,7 +3,7 @@ import classNames from "classnames"; import dayjs from "dayjs"; import { KotsPageTitle } from "@components/Head"; import CodeSnippet from "../shared/CodeSnippet"; -import NodeRow from "./NodeRow"; +import KurlNodeRow from "./KurlNodeRow"; import Loader from "../shared/Loader"; import { rbacRoles } from "../../constants/rbac"; import { Utilities } from "../../utilities/utilities"; @@ -11,10 +11,10 @@ import { Repeater } from "../../utilities/repeater"; import ErrorModal from "../modals/ErrorModal"; import Modal from "react-modal"; -import "@src/scss/components/apps/ClusterNodes.scss"; +import "@src/scss/components/apps/KurlClusterManagement.scss"; import Icon from "../Icon"; -export class ClusterNodes extends Component { +export class KurlClusterManagement extends Component { state = { generating: false, command: "", @@ -287,7 +287,7 @@ export class ClusterNodes extends Component { ); } return ( -
+
@@ -298,7 +298,7 @@ export class ClusterNodes extends Component {
{kurl?.nodes && kurl?.nodes.map((node, i) => ( - { @@ -59,7 +59,7 @@ export default function NodeRow(props) { }; return ( -
+

@@ -71,7 +71,7 @@ export default function NodeRow(props) { )}

-
+

-
+

diff --git a/web/src/components/apps/NodeRow.test.js b/web/src/components/apps/KurlNodeRow.test.js similarity index 64% rename from web/src/components/apps/NodeRow.test.js rename to web/src/components/apps/KurlNodeRow.test.js index 7c045a60a2..415445998a 100644 --- a/web/src/components/apps/NodeRow.test.js +++ b/web/src/components/apps/KurlNodeRow.test.js @@ -1,3 +1,3 @@ -describe("NodeRow", () => { +describe("KurlNodeRow", () => { it.todo("upgrade to react 18 and add unit tests"); }); diff --git a/web/src/components/shared/NavBar.tsx b/web/src/components/shared/NavBar.tsx index 48d60f8a4e..6f937c6226 100644 --- a/web/src/components/shared/NavBar.tsx +++ b/web/src/components/shared/NavBar.tsx @@ -19,6 +19,7 @@ type Props = { isHelmManaged: boolean; isIdentityServiceSupported: boolean; isKurlEnabled: boolean; + isHelmVMEnabled: boolean; isSnapshotsSupported: boolean; logo: string | null; onLogoutError: (message: string) => void; @@ -143,6 +144,7 @@ export class NavBar extends PureComponent { className, fetchingMetadata, isKurlEnabled, + isHelmVMEnabled, isGitOpsSupported, isIdentityServiceSupported, appsList, @@ -226,7 +228,7 @@ export class NavBar extends PureComponent {

)} - {isKurlEnabled && ( + {(isKurlEnabled || isHelmVMEnabled) && (
+
diff --git a/web/src/features/AppVersionHistory/YamlErrors.test.jsx b/web/src/features/AppVersionHistory/YamlErrors.test.jsx index 7c045a60a2..73e56f4c29 100644 --- a/web/src/features/AppVersionHistory/YamlErrors.test.jsx +++ b/web/src/features/AppVersionHistory/YamlErrors.test.jsx @@ -1,3 +1,3 @@ -describe("NodeRow", () => { +describe("YamlErrors", () => { it.todo("upgrade to react 18 and add unit tests"); }); diff --git a/web/src/scss/components/apps/AppVersionHistory.scss b/web/src/scss/components/apps/AppVersionHistory.scss index 8b58577224..6c9f353166 100644 --- a/web/src/scss/components/apps/AppVersionHistory.scss +++ b/web/src/scss/components/apps/AppVersionHistory.scss @@ -97,53 +97,6 @@ $cell-width: 140px; width: $cell-width; } -.ActiveDownstreamVersionRow--wrapper, -.NodeRow--wrapper { - padding: 12px; - border-top: 1px solid #dfdfdf; - - &.is-expired { - background-color: $sub-nav-color; - } - - &.is-deleting { - background-color: $sub-nav-color; - } - - .nodeTag { - background-color: $primary-light-color; - font-size: 12px; - line-height: 12px; - color: #73a3cd; - justify-content: center; - border-radius: 3px; - padding: 2px 4px 2px 4px; - } -} - -.NodeRow--items p { - max-width: 180px; - - .icon { - vertical-align: -3px; - margin-right: 4px; - } - - .node-status { - width: 6px; - height: 6px; - border-radius: 100%; - margin-right: 4px; - display: inline-block; - vertical-align: 1px; - background-color: #44bb66; - - &.disconnected { - background-color: #bc4752; - } - } -} - .gh-version-detail-text { position: relative; top: 1px; diff --git a/web/src/scss/components/apps/ClusterNodes.scss b/web/src/scss/components/apps/ClusterNodes.scss deleted file mode 100644 index 1177761a02..0000000000 --- a/web/src/scss/components/apps/ClusterNodes.scss +++ /dev/null @@ -1,31 +0,0 @@ -.ClusterNodes--wrapper { - .BoxedCheckbox { - height: 85px; - width: 200px; - } - - .timestamp { - position: relative; - margin-top: -10px; - z-index: -1; - } - - .node-label { - font-size: 12px; - font-weight: 500; - line-height: 12px; - color: #577981; - padding: 4px 6px; - border-radius: 20px; - background-color: #ffffff; - white-space: nowrap; - border: 1px solid #577981; - margin-right: 8px; - display: inline-block; - margin-top: 8px; - - &:last-child { - margin-right: 0; - } - } -} diff --git a/web/src/scss/components/apps/HelmVMClusterManagement.scss b/web/src/scss/components/apps/HelmVMClusterManagement.scss new file mode 100644 index 0000000000..cd8bc74a01 --- /dev/null +++ b/web/src/scss/components/apps/HelmVMClusterManagement.scss @@ -0,0 +1,79 @@ +@import "../../variables.scss"; + +.HelmVMClusterManagement--wrapper { + .BoxedCheckbox { + height: 85px; + width: 200px; + } + + .timestamp { + position: relative; + margin-top: -10px; + z-index: -1; + } + + .node-label { + font-size: 12px; + font-weight: 500; + line-height: 12px; + color: #577981; + padding: 4px 6px; + border-radius: 20px; + background-color: #ffffff; + white-space: nowrap; + border: 1px solid #577981; + margin-right: 8px; + display: inline-block; + margin-top: 8px; + + &:last-child { + margin-right: 0; + } + } +} + +.HelmVMNodeRow--wrapper { + padding: 12px; + border-top: 1px solid #dfdfdf; + + &.is-expired { + background-color: $sub-nav-color; + } + + &.is-deleting { + background-color: $sub-nav-color; + } + + .nodeTag { + background-color: $primary-light-color; + font-size: 12px; + line-height: 12px; + color: #73a3cd; + justify-content: center; + border-radius: 3px; + padding: 2px 4px 2px 4px; + } +} + +.HelmVMNodeRow--items p { + max-width: 180px; + + .icon { + vertical-align: -3px; + margin-right: 4px; + } + + .node-status { + width: 6px; + height: 6px; + border-radius: 100%; + margin-right: 4px; + display: inline-block; + vertical-align: 1px; + background-color: #44bb66; + + &.disconnected { + background-color: #bc4752; + } + } +} diff --git a/web/src/scss/components/apps/KurlClusterManagement.scss b/web/src/scss/components/apps/KurlClusterManagement.scss new file mode 100644 index 0000000000..5a8b39a8bf --- /dev/null +++ b/web/src/scss/components/apps/KurlClusterManagement.scss @@ -0,0 +1,79 @@ +@import "../../variables.scss"; + +.KurlClusterManagement--wrapper { + .BoxedCheckbox { + height: 85px; + width: 200px; + } + + .timestamp { + position: relative; + margin-top: -10px; + z-index: -1; + } + + .node-label { + font-size: 12px; + font-weight: 500; + line-height: 12px; + color: #577981; + padding: 4px 6px; + border-radius: 20px; + background-color: #ffffff; + white-space: nowrap; + border: 1px solid #577981; + margin-right: 8px; + display: inline-block; + margin-top: 8px; + + &:last-child { + margin-right: 0; + } + } +} + +.KurlNodeRow--wrapper { + padding: 12px; + border-top: 1px solid #dfdfdf; + + &.is-expired { + background-color: $sub-nav-color; + } + + &.is-deleting { + background-color: $sub-nav-color; + } + + .nodeTag { + background-color: $primary-light-color; + font-size: 12px; + line-height: 12px; + color: #73a3cd; + justify-content: center; + border-radius: 3px; + padding: 2px 4px 2px 4px; + } +} + +.KurlNodeRow--items p { + max-width: 180px; + + .icon { + vertical-align: -3px; + margin-right: 4px; + } + + .node-status { + width: 6px; + height: 6px; + border-radius: 100%; + margin-right: 4px; + display: inline-block; + vertical-align: 1px; + background-color: #44bb66; + + &.disconnected { + background-color: #bc4752; + } + } +} diff --git a/web/src/scss/components/troubleshoot/SupportBundleAnalysis.scss b/web/src/scss/components/troubleshoot/SupportBundleAnalysis.scss index 00b4f5f7d3..b3d61bd548 100644 --- a/web/src/scss/components/troubleshoot/SupportBundleAnalysis.scss +++ b/web/src/scss/components/troubleshoot/SupportBundleAnalysis.scss @@ -402,9 +402,14 @@ } } -.ActiveDownstreamVersionRow--wrapper .icon.u-iconFullArrowGray { - margin-left: 10px; - top: 2px; +.RedactorReportRow--wrapper { + padding: 12px; + border-top: 1px solid #dfdfdf; + + .icon.u-iconFullArrowGray { + margin-left: 100px; + top: 20px; + } } /* ≥ 960px */ diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 787f3220a4..269d6d1b35 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -130,6 +130,7 @@ export type Entitlement = { export type Metadata = { isAirgap: boolean; isKurl: boolean; + isHelmVM: boolean; }; export type PreflightError = {