package host import ( "context" "errors" "fmt" "net/url" "os" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" "github.com/go-playground/validator/v10" "github.com/spf13/afero" v1 "k8s.io/api/core/v1" toolscache "k8s.io/client-go/tools/cache" "sigs.k8s.io/controller-runtime/pkg/client" "edge-infra.dev/pkg/lib/logging" "edge-infra.dev/pkg/sds/interlock/internal/config" "edge-infra.dev/pkg/sds/interlock/internal/constants" "edge-infra.dev/pkg/sds/interlock/topic" "edge-infra.dev/pkg/sds/interlock/topic/host/internal/middleware" "edge-infra.dev/pkg/sds/interlock/websocket" ) var ( // Host represents the local node. TopicName = "host" Path = "/v1/host" ) // Host represents the local node type Host struct { topic topic.Topic } // New returns a new Host and configures the topic with the name, initialized // state and websocket manager func New(ctx context.Context, cfg *config.Config, wm *websocket.Manager) (*Host, error) { state, err := newState(ctx, cfg) if err != nil { return nil, err } topic := topic.NewTopic( TopicName, state, cfg, wm, ) if v, ok := binding.Validator.Engine().(*validator.Validate); ok { if err := v.RegisterValidation("is_zero_xor_is_connected", validateFieldIsZeroXORStatusIsConnected); err != nil { return nil, err } } else { return nil, errors.New("could not register custom binding validation: binding validator is not of type validator.Validate") } host := &Host{ topic: topic, } err = host.SetupAPIInformers(ctx, cfg) return host, err } // RegistersEndpoints registers the Host topic endpoints on the provided router func (h *Host) RegisterEndpoints(r *gin.Engine) { v1 := r.Group(Path) v1.GET("", h.topic.DefaultGet) v1.PATCH("", middleware.ReadOnlyFieldValidation(), middleware.BlockVNC(), h.topic.DefaultPatch) v1.POST("/vnc", h.VNCPost) v1.PUT("/vnc", h.VNCPut) v1.POST("/kpower", h.KpowerPost) v1.PUT("/kpower", h.KpowerPut) } // SetupAPIInformers adds the NodeUIDEventHandler to the Node informer // present in the config that is used in the Interlock API server. // This allows the Interlock's host topic to respond to Node events appropriately func (h *Host) SetupAPIInformers(ctx context.Context, cfg *config.Config) error { // Get nodes informer informer, err := cfg.Cache.GetInformer(ctx, &v1.Node{}) if err != nil { return fmt.Errorf("failed to get node resource informer: %w", err) } // Create event handler for the host node UID nodeFilteredEventHandler := toolscache.FilteringResourceEventHandler{ FilterFunc: IsHostNode, Handler: &NodeUIDEventHandler{h.topic.UpdateState}, } _, err = informer.AddEventHandler(nodeFilteredEventHandler) if err != nil { return fmt.Errorf("failed to add node event handler: %w", err) } return nil } // IsHostNode returns true if the provided object is the node this interlock instance is on // otherwise false func IsHostNode(obj interface{}) bool { node, ok := obj.(*v1.Node) if !ok { return false } return node.GetName() == os.Getenv("NODE_NAME") } // NodeUIDEventHandler handles keeping the host node UID up to date by watching // Kubernetes events type NodeUIDEventHandler struct { UpdateFunc func(topic.UpdateFunc) error } // OnAdd updates the host node-uid on Node create events func (nueh *NodeUIDEventHandler) OnAdd(obj interface{}, _ bool) { node := obj.(*v1.Node) updateNodeUID(nueh.UpdateFunc, node) } // OnUpdate ignores update events since uid is a read-only field func (nueh *NodeUIDEventHandler) OnUpdate(_, _ interface{}) { } // OnDelete ignores delete events func (nueh *NodeUIDEventHandler) OnDelete(_ interface{}) {} // updateNodeUID retrieves the node UID from the given Node object and assigns // it to the host node-uid field func updateNodeUID(updateFunc func(topic.UpdateFunc) error, node *v1.Node) { nodeUID := string(node.GetObjectMeta().GetUID()) err := updateFunc(func(obj interface{}) error { state := obj.(*State) state.NodeUID = nodeUID return nil }) if err != nil { logging.NewLogger().Error(err, "failed to update cluster name in informer hook") } } // State contains information about the local node // // swagger:model HostState type State struct { // Hostname of the host node. This field is read only and will be kept up to date internally by Interlock // // readOnly: true // example: s1-master-1 Hostname string `json:"hostname" binding:"required,hostname_rfc1123"` // UID of the host node. This field is read only and will be kept up to date internally by Interlock // // readOnly: true // example: abcdefgh-3aee-438b-a2a8-b4de3a8f470f NodeUID string `json:"node-uid"` // Network status of the host node Network Network `json:"network"` // VNCStates contains the VNC states of the host node VNC VNCStates `json:"vnc"` Kpower KpowerState `json:"kpower"` } // Network contains information about the network status of the local // node // // swagger:model type Network struct { // Whether or not a current LAN outage has been detected // // required: true LANOutageDetected bool `json:"lan-outage-detected"` // Whether or not the host node is currently in LAN outage mode // // required: true LANOutageMode bool `json:"lan-outage-mode"` } // newState returns a new host State with initialized values // LANOutageMode state is discovered from the host filesystem // NodeUID is discovered from the kubernetes Node resource func newState(ctx context.Context, cfg *config.Config) (*State, error) { hostName := os.Getenv("NODE_NAME") nodeUID, err := discoverNodeUID(ctx, cfg, hostName) if err != nil { return nil, fmt.Errorf("failed to discover node UID: %w", err) } lanOutageMode, err := discoverLANOutageMode(cfg.Fs) if err != nil { return nil, fmt.Errorf("failed to discover LAN outage mode: %w", err) } return &State{ Hostname: hostName, NodeUID: nodeUID, Network: Network{ LANOutageDetected: false, LANOutageMode: lanOutageMode, }, VNC: VNCStates{}, Kpower: KpowerState{ Status: UNKNOWN, }, }, nil } // discoverNodeUID gets the uid from the host Node object func discoverNodeUID(ctx context.Context, cfg *config.Config, hostName string) (string, error) { k8sClient := cfg.KubeRetryClient.Client() hostNode := &v1.Node{} err := k8sClient.Get(ctx, client.ObjectKey{Name: hostName}, hostNode) if err != nil { return "", err } uid := hostNode.GetObjectMeta().GetUID() return string(uid), nil } // discoverLANOutageMode reads the LAN outage mode state from the filesystem // of the host node func discoverLANOutageMode(fs afero.Fs) (bool, error) { path, err := url.JoinPath(constants.ZynstraConfigDir, constants.LANOutageModeFlagFileName) if err != nil { return false, fmt.Errorf("failed to assemble LAN outage mode flag file path: %w", err) } isLOM, err := afero.Exists(fs, path) if err != nil { return false, fmt.Errorf("failed to read LAN outage mode state from flag file: %w", err) } return isLOM, nil } // Everything below this point is used for documentation purposes and exists for // the purpose of generating the swagger spec only. // swagger:route GET /v1/host host GetHostState // Retrieve the current host state // responses: // 200: HostStateResponse // 404: ErrorResponse // 405: ErrorResponse // 500: ErrorResponse // swagger:route PATCH /v1/host host PatchHostState // Patch the host state // responses: // 202: HostStateResponse // 404: ErrorResponse // 405: ErrorResponse // 500: ErrorResponse // The PatchHostState parameters // // swagger:parameters PatchHostState type PatchParametersWrapper struct { // The host state that the client wants to patch // // in:body State State `json:"state"` } // The request was successful // // swagger:response HostStateResponse type StateResponseWrapper struct { // The current host state // // in:body State State `json:"state"` }