package corrosion import ( "encoding/binary" "encoding/json" "errors" "fmt" "io" "net" "net/netip" "time" ) // AdminClient is a client for the Corrosion admin API. type AdminClient struct { sockPath string } func NewAdminClient(sockPath string) (*AdminClient, error) { return &AdminClient{sockPath: sockPath}, nil } type Response struct { JSON map[string]any // Err is set if the response is an error or if an error occurred while processing the response. Err error } // SendCommand sends a command to the Corrosion admin API and returns a channel that will receive responses. // The channel will be closed after sending the last or error response. The caller must read from the channel until // it is closed. func (c *AdminClient) SendCommand(cmd []byte) (<-chan Response, error) { conn, err := net.Dial("unix", c.sockPath) if err != nil { return nil, fmt.Errorf("connect to admin socket: %w", err) } if _, err = conn.Write(encodeFrame(cmd)); err != nil { conn.Close() return nil, fmt.Errorf("send command: %w", err) } ch := make(chan Response) go func() { defer close(ch) defer conn.Close() for { r := Response{} data, err := readFrame(conn) if err != nil { r.Err = err ch <- r return } var decoded any if err = json.Unmarshal(data, &decoded); err != nil { r.Err = fmt.Errorf("unmarshal response: %w", err) ch <- r return } switch v := decoded.(type) { case string: if v == "Success" { return } // Ignore other strings. case map[string]any: if errData, ok := v["Error"].(map[string]any); ok { if errMsg, ok := errData["msg"].(string); ok { r.Err = errors.New(errMsg) } else { r.Err = fmt.Errorf("invalid error response: %v", errData) } ch <- r return } else if jsonData, ok := v["Json"].(map[string]any); ok { r.JSON = jsonData ch <- r } // Ignore other maps. default: // Ignore other types. } } }() return ch, nil } // encodeFrame encodes a length_delimited Tokio frame by prefacing frame data with a frame head that specifies // the length of the frame. func encodeFrame(data []byte) []byte { encoded := make([]byte, 4+len(data)) binary.BigEndian.PutUint32(encoded, uint32(len(data))) copy(encoded[4:], data) return encoded } // readFrame reads a length_delimited Tokio frame from the connection by extracting the frame data that follows // the frame head. func readFrame(conn net.Conn) ([]byte, error) { // Read the frame head (4 bytes). head := make([]byte, 4) if _, err := io.ReadFull(conn, head); err != nil { return nil, fmt.Errorf("read frame head: %w", err) } // Read the frame data (length specified in the frame head). length := binary.BigEndian.Uint32(head) data := make([]byte, length) if _, err := io.ReadFull(conn, data); err != nil { return nil, fmt.Errorf("read frame data: %w", err) } return data, nil } type ClusterMembershipState struct { ID string Addr netip.AddrPort State string Timestamp time.Time } var ( // MembershipStateAlive indicates that the member is active. MembershipStateAlive string = "Alive" // MembershipStateSuspect indicates that the member is active, but at least one cluster member suspects its down. // For all purposes, a Suspect member is treated as if it were Alive until either it refutes the suspicion // (becoming Alive) or fails to do so (being declared Down). MembershipStateSuspect string = "Suspect" // MembershipStateDown indicates that the member is confirmed Down. A member that reaches this state can't join // the cluster with the same identity until the cluster forgets this knowledge. MembershipStateDown string = "Down" ) func parseClusterMembershipState(json map[string]any) (ClusterMembershipState, error) { // Example JSON: // { // "id": { // "addr": "[fdcc:1d51:6bae:6bb2:53c0:8796:1be0:b783]:51001", // "cluster_id": 0, // "id": "10d69d6f-6578-4dcf-a285-e860e40c5f06", // "ts": 7435936225798880256 // }, // "incarnation": 0, // "state": "Down" // } var state ClusterMembershipState var err error idObj, ok := json["id"].(map[string]any) if !ok { return state, fmt.Errorf("missing or invalid 'id' field") } // ID if id, ok := idObj["id"].(string); ok { state.ID = id } else { return state, fmt.Errorf("missing or invalid 'id' field") } // Addr if addr, ok := idObj["addr"].(string); ok { state.Addr, err = netip.ParseAddrPort(addr) if err != nil { return state, fmt.Errorf("parse 'addr' field: %w", err) } } else { return state, fmt.Errorf("missing or invalid 'addr' field") } // State if stateStr, ok := json["state"].(string); ok { switch stateStr { case MembershipStateAlive, MembershipStateSuspect, MembershipStateDown: state.State = stateStr default: return state, fmt.Errorf("invalid 'state' field: %s", stateStr) } } else { return state, fmt.Errorf("missing or invalid 'state' field") } // Timestamp if ts, ok := idObj["ts"].(float64); ok { state.Timestamp = ntp64ToTime(uint64(ts)) } else { return state, fmt.Errorf("missing or invalid 'ts' field") } return state, nil } // nt64ToTime converts a 64-bit NTP timestamp relative to the Unix epoch (1st Jan 1970) to time.Time. // See for more details: https://datatracker.ietf.org/doc/html/rfc5905#section-6 func ntp64ToTime(ntp uint64) time.Time { // The NTP timestamp returned from Corrosion is relative to the Unix epoch (1st Jan 1970) // so no need to subtract the 70 years offset. secs := uint32(ntp >> 32) frac := uint32(ntp) // Convert the fraction to nanoseconds: frac * 1e9 / 2^32 nsecs := (uint64(frac) * 1000_000_000) >> 32 return time.Unix(int64(secs), int64(nsecs)) } // ClusterMembershipStates returns the current membership SWIM states of all cluster members. // If latest is true, only the latest state of each member is returned. func (c *AdminClient) ClusterMembershipStates(latest bool) ([]ClusterMembershipState, error) { respCh, err := c.SendCommand([]byte("{\"Cluster\":\"MembershipStates\"}")) if err != nil { return nil, err } var ( states []ClusterMembershipState latestStates map[string]ClusterMembershipState parseErr error ) if latest { latestStates = make(map[string]ClusterMembershipState) } for r := range respCh { if r.Err != nil { // It's safe to return here because the channel is closed after the first error response. return nil, r.Err } s, err := parseClusterMembershipState(r.JSON) if err != nil { // Do not return early to drain the channel. parseErr = errors.Join(parseErr, err) } else { if latest { if existing, ok := latestStates[s.ID]; !ok || existing.Timestamp.Before(s.Timestamp) { latestStates[s.ID] = s } } else { states = append(states, s) } } } if latest { states = make([]ClusterMembershipState, 0, len(latestStates)) for _, s := range latestStates { states = append(states, s) } } return states, parseErr }