From c945a7f2a1199196f098ba8e0724b3a41cf79976 Mon Sep 17 00:00:00 2001 From: Matee Ullah Malik Date: Fri, 18 Jul 2025 15:21:12 +0500 Subject: [PATCH] feat: Add capabilities management to supernode --- DESIGN.md | 226 ++++++++++++ gen/supernode/supernode.pb.go | 321 ++++++++++++++++-- gen/supernode/supernode_grpc.pb.go | 40 ++- pkg/capabilities/compatibility_manager.go | 121 +++++++ .../compatibility_manager_test.go | 238 +++++++++++++ pkg/capabilities/config_manager.go | 28 ++ pkg/capabilities/config_manager_test.go | 160 +++++++++ pkg/capabilities/interfaces.go | 13 + pkg/capabilities/peer_compatibility.go | 95 ++++++ pkg/capabilities/service.go | 37 ++ pkg/capabilities/test_config.yaml | 13 + pkg/capabilities/types.go | 36 ++ proto/supernode/supernode.proto | 19 ++ supernode/cmd/capabilities.go | 121 +++++++ supernode/cmd/start.go | 13 +- .../node/supernode/server/status_server.go | 34 +- .../supernode/server/status_server_test.go | 105 +++++- 17 files changed, 1582 insertions(+), 38 deletions(-) create mode 100644 DESIGN.md create mode 100644 pkg/capabilities/compatibility_manager.go create mode 100644 pkg/capabilities/compatibility_manager_test.go create mode 100644 pkg/capabilities/config_manager.go create mode 100644 pkg/capabilities/config_manager_test.go create mode 100644 pkg/capabilities/interfaces.go create mode 100644 pkg/capabilities/peer_compatibility.go create mode 100644 pkg/capabilities/service.go create mode 100644 pkg/capabilities/test_config.yaml create mode 100644 pkg/capabilities/types.go create mode 100644 supernode/cmd/capabilities.go diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..371b68af --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,226 @@ +`````` +# Version and Compatibility Management + +## Overview + +The Supernode Compatibility System is designed to enable version compatibility and capability matching across the Lumera supernode network. This system provides a foundation for future mesh-based task coordination while maintaining backward compatibility with existing supernodes. + +The design introduces a capability advertisement and verification mechanism that allows supernodes to: +- Advertise their version and supported actions +- Query peer capabilities for compatibility checks +- Make informed decisions about task coordination +- Gracefully handle version mismatches + +## Architecture + +### High-Level Components + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ gRPC Service │ │ Compatibility │ │ Configuration │ +│ (External) │◄──►│ Manager │◄──►│ Manager │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ +``` + +### Component Responsibilities + +1. **Configuration Manager**: Handles loading and validation of capability configuration from YAML +2. **Compatibility Manager**: Core logic for version comparison and compatibility checking +3. **gRPC Service Extension**: New RPC methods integrated into existing supernode service + +## Components and Interfaces + +### Configuration Manager + +```go +type ConfigManager interface { + LoadConfig(path string) (*CapabilityConfig, error) +} + +type CapabilityConfig struct { + Version string `yaml:"version"` + SupportedActions []string `yaml:"supported_actions"` + ActionVersions map[string][]string `yaml:"action_versions"` + Metadata map[string]string `yaml:"metadata,omitempty"` +} +``` + + + +### Compatibility Manager + +```go +type CompatibilityManager interface { + CheckCompatibility(local, peer *Capabilities) (*CompatibilityResult, error) + IsVersionCompatible(localVersion, peerVersion string) (bool, error) + HasRequiredCapabilities(caps *Capabilities, required []string) bool +} + +type CompatibilityResult struct { + Compatible bool + Reason string + Details map[string]interface{} +} +``` + + + +### gRPC Service Integration + +```protobuf +service SupernodeService { + // Existing methods... + + // New capability method + rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); +} + +message Capabilities { + string version = 1; + repeated string supported_actions = 2; + map action_versions = 3; + map metadata = 4; + int64 timestamp = 5; +} + +message ActionVersions { + repeated string versions = 1; +} +``` + + + +## Data Models + +### Core Data Structures + +```go +type Capabilities struct { + Version string `json:"version"` + SupportedActions []string `json:"supported_actions"` + ActionVersions map[string][]string `json:"action_versions"` + Metadata map[string]string `json:"metadata,omitempty"` + Timestamp time.Time `json:"tim + +type VersionInfo struct { + Major int `json:"major"` + Minor int `json:"minor"` + Patch int `json:"patch"` +} +``` + +### Configuration Schema + +```yaml +# capabilities.yaml +version: "1.2.3" +supported_actions: + - "cascade" + - "sense" + - "storage_challenge" +action_versions: + cascade: ["1.0.0", "1.1.0", "1.2.0"] + sense: ["2.0.0", "2.1.0"] +metadata: + build_info: "go1.24.1" + features: "raptorq,sqlite" +``` +`````` + + +## Lumera Blockchain Integration + +### Supernode Parameters Enhancement + +Instead of relying solely on local YAML configuration, the system integrates with Lumera's blockchain parameters to maintain network-wide action version compatibility: + +```protobuf +// In lumera/proto/lumera/supernode/params.proto +message ActionVersions { + repeated string versions = 1; +} + +message Params { + // ... existing fields ... + + // Action versions define the supported version ranges for each available action + map action_versions = 8 [ + (gogoproto.moretags) = "yaml:\"action_versions\"" + ]; +} +``` + +### Benefits of Blockchain Integration + +- **Network-wide Coordination**: All supernodes can query the same action version requirements from the blockchain +- **Governance Control**: Action versions can be updated through blockchain governance proposals +- **Fallback Compatibility**: Even if semantic versioning doesn't match, supernodes can still coordinate using available capabilities defined in blockchain parameters +- **Dynamic Updates**: Version requirements can be updated without supernode restarts + +### Compatibility Strategy + +The system uses a multi-layered compatibility approach: + +1. **Semantic Version Matching**: Primary compatibility check using major version matching +2. **Action Version Intersection**: Secondary check using blockchain-defined version ranges +3. **Graceful Degradation**: Fallback to compatible subset of actions when full compatibility isn't available + +```go +// Enhanced compatibility checking with blockchain parameters +func (cm *CompatibilityManager) CheckCompatibilityWithBlockchain( + local, peer *Capabilities, + networkActionVersions map[string][]string, +) (*CompatibilityResult, error) { + // 1. Check semantic version compatibility + if compatible, _ := cm.IsVersionCompatible(local.Version, peer.Version); compatible { + return &CompatibilityResult{ + Compatible: true, + Reason: "version_compatible", + }, nil + } + + // 2. Check action version intersection with network parameters + compatibleActions := []string{} + for action, networkVersions := range networkActionVersions { + if cm.hasVersionIntersection(local.ActionVersions[action], networkVersions) && + cm.hasVersionIntersection(peer.ActionVersions[action], networkVersions) { + compatibleActions = append(compatibleActions, action) + } + } + + if len(compatibleActions) > 0 { + return &CompatibilityResult{ + Compatible: true, + Reason: "partial_compatibility", + Details: map[string]interface{}{ + "compatible_actions": compatibleActions, + }, + }, nil + } + + return &CompatibilityResult{Compatible: false, Reason: "incompatible"}, nil +} +``` + +### Usage Example + +```yaml +# Local capabilities.yaml (supernode-specific) +version: "1.2.3" +supported_actions: + - "cascade" + - "sense" +action_versions: + cascade: ["1.0.0", "1.1.0", "1.2.0"] + sense: ["2.0.0", "2.1.0"] +``` + +```bash +# Network parameters (blockchain-managed) +action_versions: + cascade: ["1.0.0", "1.1.0", "1.2.0", "1.3.0"] + sense: ["2.0.0", "2.1.0", "2.2.0"] + storage_challenge: ["1.0.0"] +``` + +In this example, even if two supernodes have different semantic versions, they can still coordinate on `cascade` and `sense` actions as long as their action versions intersect with the network-defined ranges. \ No newline at end of file diff --git a/gen/supernode/supernode.pb.go b/gen/supernode/supernode.pb.go index 0b3610f1..a9b3c153 100644 --- a/gen/supernode/supernode.pb.go +++ b/gen/supernode/supernode.pb.go @@ -126,6 +126,209 @@ func (x *StatusResponse) GetAvailableServices() []string { return nil } +type GetCapabilitiesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *GetCapabilitiesRequest) Reset() { + *x = GetCapabilitiesRequest{} + mi := &file_supernode_supernode_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesRequest) ProtoMessage() {} + +func (x *GetCapabilitiesRequest) ProtoReflect() protoreflect.Message { + mi := &file_supernode_supernode_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCapabilitiesRequest.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesRequest) Descriptor() ([]byte, []int) { + return file_supernode_supernode_proto_rawDescGZIP(), []int{2} +} + +type GetCapabilitiesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Capabilities *Capabilities `protobuf:"bytes,1,opt,name=capabilities,proto3" json:"capabilities,omitempty"` +} + +func (x *GetCapabilitiesResponse) Reset() { + *x = GetCapabilitiesResponse{} + mi := &file_supernode_supernode_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCapabilitiesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCapabilitiesResponse) ProtoMessage() {} + +func (x *GetCapabilitiesResponse) ProtoReflect() protoreflect.Message { + mi := &file_supernode_supernode_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCapabilitiesResponse.ProtoReflect.Descriptor instead. +func (*GetCapabilitiesResponse) Descriptor() ([]byte, []int) { + return file_supernode_supernode_proto_rawDescGZIP(), []int{3} +} + +func (x *GetCapabilitiesResponse) GetCapabilities() *Capabilities { + if x != nil { + return x.Capabilities + } + return nil +} + +type Capabilities struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + SupportedActions []string `protobuf:"bytes,2,rep,name=supported_actions,json=supportedActions,proto3" json:"supported_actions,omitempty"` + ActionVersions map[string]*ActionVersions `protobuf:"bytes,3,rep,name=action_versions,json=actionVersions,proto3" json:"action_versions,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + Metadata map[string]string `protobuf:"bytes,4,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + Timestamp int64 `protobuf:"varint,5,opt,name=timestamp,proto3" json:"timestamp,omitempty"` +} + +func (x *Capabilities) Reset() { + *x = Capabilities{} + mi := &file_supernode_supernode_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Capabilities) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Capabilities) ProtoMessage() {} + +func (x *Capabilities) ProtoReflect() protoreflect.Message { + mi := &file_supernode_supernode_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Capabilities.ProtoReflect.Descriptor instead. +func (*Capabilities) Descriptor() ([]byte, []int) { + return file_supernode_supernode_proto_rawDescGZIP(), []int{4} +} + +func (x *Capabilities) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *Capabilities) GetSupportedActions() []string { + if x != nil { + return x.SupportedActions + } + return nil +} + +func (x *Capabilities) GetActionVersions() map[string]*ActionVersions { + if x != nil { + return x.ActionVersions + } + return nil +} + +func (x *Capabilities) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *Capabilities) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +type ActionVersions struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Versions []string `protobuf:"bytes,1,rep,name=versions,proto3" json:"versions,omitempty"` +} + +func (x *ActionVersions) Reset() { + *x = ActionVersions{} + mi := &file_supernode_supernode_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ActionVersions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ActionVersions) ProtoMessage() {} + +func (x *ActionVersions) ProtoReflect() protoreflect.Message { + mi := &file_supernode_supernode_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ActionVersions.ProtoReflect.Descriptor instead. +func (*ActionVersions) Descriptor() ([]byte, []int) { + return file_supernode_supernode_proto_rawDescGZIP(), []int{5} +} + +func (x *ActionVersions) GetVersions() []string { + if x != nil { + return x.Versions + } + return nil +} + type StatusResponse_CPU struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -137,7 +340,7 @@ type StatusResponse_CPU struct { func (x *StatusResponse_CPU) Reset() { *x = StatusResponse_CPU{} - mi := &file_supernode_supernode_proto_msgTypes[2] + mi := &file_supernode_supernode_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -149,7 +352,7 @@ func (x *StatusResponse_CPU) String() string { func (*StatusResponse_CPU) ProtoMessage() {} func (x *StatusResponse_CPU) ProtoReflect() protoreflect.Message { - mi := &file_supernode_supernode_proto_msgTypes[2] + mi := &file_supernode_supernode_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -192,7 +395,7 @@ type StatusResponse_Memory struct { func (x *StatusResponse_Memory) Reset() { *x = StatusResponse_Memory{} - mi := &file_supernode_supernode_proto_msgTypes[3] + mi := &file_supernode_supernode_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -204,7 +407,7 @@ func (x *StatusResponse_Memory) String() string { func (*StatusResponse_Memory) ProtoMessage() {} func (x *StatusResponse_Memory) ProtoReflect() protoreflect.Message { - mi := &file_supernode_supernode_proto_msgTypes[3] + mi := &file_supernode_supernode_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -261,7 +464,7 @@ type StatusResponse_ServiceTasks struct { func (x *StatusResponse_ServiceTasks) Reset() { *x = StatusResponse_ServiceTasks{} - mi := &file_supernode_supernode_proto_msgTypes[4] + mi := &file_supernode_supernode_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -273,7 +476,7 @@ func (x *StatusResponse_ServiceTasks) String() string { func (*StatusResponse_ServiceTasks) ProtoMessage() {} func (x *StatusResponse_ServiceTasks) ProtoReflect() protoreflect.Message { - mi := &file_supernode_supernode_proto_msgTypes[4] + mi := &file_supernode_supernode_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -348,17 +551,59 @@ var file_supernode_supernode_proto_rawDesc = []byte{ 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x61, 0x73, 0x6b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x32, - 0x54, 0x0a, 0x10, 0x53, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x12, 0x40, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x12, 0x18, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x73, 0x75, 0x70, - 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, - 0x63, 0x6f, 0x6d, 0x2f, 0x4c, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, - 0x6f, 0x6c, 0x2f, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2f, 0x67, 0x65, 0x6e, - 0x2f, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x61, 0x73, 0x6b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, + 0x18, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, + 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x56, 0x0a, 0x17, 0x47, 0x65, 0x74, + 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, + 0x74, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x73, 0x75, 0x70, + 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, + 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, + 0x73, 0x22, 0xa7, 0x03, 0x0a, 0x0c, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, + 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2b, 0x0a, 0x11, + 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, + 0x65, 0x64, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x54, 0x0a, 0x0f, 0x61, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x43, + 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x2e, 0x41, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, + 0x0e, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, + 0x41, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x25, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x43, 0x61, + 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x1a, 0x5c, 0x0a, 0x13, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2f, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, + 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x56, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x3b, + 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2c, 0x0a, 0x0e, 0x41, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x1a, 0x0a, + 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x32, 0xae, 0x01, 0x0a, 0x10, 0x53, 0x75, + 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x40, + 0x0a, 0x09, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x2e, 0x73, 0x75, + 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, + 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x58, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, + 0x69, 0x65, 0x73, 0x12, 0x21, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x2e, + 0x47, 0x65, 0x74, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, + 0x64, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, + 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x4c, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x50, + 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x2f, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, + 0x65, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x73, 0x75, 0x70, 0x65, 0x72, 0x6e, 0x6f, 0x64, 0x65, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -373,25 +618,37 @@ func file_supernode_supernode_proto_rawDescGZIP() []byte { return file_supernode_supernode_proto_rawDescData } -var file_supernode_supernode_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_supernode_supernode_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_supernode_supernode_proto_goTypes = []any{ (*StatusRequest)(nil), // 0: supernode.StatusRequest (*StatusResponse)(nil), // 1: supernode.StatusResponse - (*StatusResponse_CPU)(nil), // 2: supernode.StatusResponse.CPU - (*StatusResponse_Memory)(nil), // 3: supernode.StatusResponse.Memory - (*StatusResponse_ServiceTasks)(nil), // 4: supernode.StatusResponse.ServiceTasks + (*GetCapabilitiesRequest)(nil), // 2: supernode.GetCapabilitiesRequest + (*GetCapabilitiesResponse)(nil), // 3: supernode.GetCapabilitiesResponse + (*Capabilities)(nil), // 4: supernode.Capabilities + (*ActionVersions)(nil), // 5: supernode.ActionVersions + (*StatusResponse_CPU)(nil), // 6: supernode.StatusResponse.CPU + (*StatusResponse_Memory)(nil), // 7: supernode.StatusResponse.Memory + (*StatusResponse_ServiceTasks)(nil), // 8: supernode.StatusResponse.ServiceTasks + nil, // 9: supernode.Capabilities.ActionVersionsEntry + nil, // 10: supernode.Capabilities.MetadataEntry } var file_supernode_supernode_proto_depIdxs = []int32{ - 2, // 0: supernode.StatusResponse.cpu:type_name -> supernode.StatusResponse.CPU - 3, // 1: supernode.StatusResponse.memory:type_name -> supernode.StatusResponse.Memory - 4, // 2: supernode.StatusResponse.services:type_name -> supernode.StatusResponse.ServiceTasks - 0, // 3: supernode.SupernodeService.GetStatus:input_type -> supernode.StatusRequest - 1, // 4: supernode.SupernodeService.GetStatus:output_type -> supernode.StatusResponse - 4, // [4:5] is the sub-list for method output_type - 3, // [3:4] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 6, // 0: supernode.StatusResponse.cpu:type_name -> supernode.StatusResponse.CPU + 7, // 1: supernode.StatusResponse.memory:type_name -> supernode.StatusResponse.Memory + 8, // 2: supernode.StatusResponse.services:type_name -> supernode.StatusResponse.ServiceTasks + 4, // 3: supernode.GetCapabilitiesResponse.capabilities:type_name -> supernode.Capabilities + 9, // 4: supernode.Capabilities.action_versions:type_name -> supernode.Capabilities.ActionVersionsEntry + 10, // 5: supernode.Capabilities.metadata:type_name -> supernode.Capabilities.MetadataEntry + 5, // 6: supernode.Capabilities.ActionVersionsEntry.value:type_name -> supernode.ActionVersions + 0, // 7: supernode.SupernodeService.GetStatus:input_type -> supernode.StatusRequest + 2, // 8: supernode.SupernodeService.GetCapabilities:input_type -> supernode.GetCapabilitiesRequest + 1, // 9: supernode.SupernodeService.GetStatus:output_type -> supernode.StatusResponse + 3, // 10: supernode.SupernodeService.GetCapabilities:output_type -> supernode.GetCapabilitiesResponse + 9, // [9:11] is the sub-list for method output_type + 7, // [7:9] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_supernode_supernode_proto_init() } @@ -405,7 +662,7 @@ func file_supernode_supernode_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_supernode_supernode_proto_rawDesc, NumEnums: 0, - NumMessages: 5, + NumMessages: 11, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/supernode/supernode_grpc.pb.go b/gen/supernode/supernode_grpc.pb.go index 783c3f8c..7c367a99 100644 --- a/gen/supernode/supernode_grpc.pb.go +++ b/gen/supernode/supernode_grpc.pb.go @@ -19,7 +19,8 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - SupernodeService_GetStatus_FullMethodName = "/supernode.SupernodeService/GetStatus" + SupernodeService_GetStatus_FullMethodName = "/supernode.SupernodeService/GetStatus" + SupernodeService_GetCapabilities_FullMethodName = "/supernode.SupernodeService/GetCapabilities" ) // SupernodeServiceClient is the client API for SupernodeService service. @@ -29,6 +30,7 @@ const ( // SupernodeService provides status information for all services type SupernodeServiceClient interface { GetStatus(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) + GetCapabilities(ctx context.Context, in *GetCapabilitiesRequest, opts ...grpc.CallOption) (*GetCapabilitiesResponse, error) } type supernodeServiceClient struct { @@ -49,6 +51,16 @@ func (c *supernodeServiceClient) GetStatus(ctx context.Context, in *StatusReques return out, nil } +func (c *supernodeServiceClient) GetCapabilities(ctx context.Context, in *GetCapabilitiesRequest, opts ...grpc.CallOption) (*GetCapabilitiesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetCapabilitiesResponse) + err := c.cc.Invoke(ctx, SupernodeService_GetCapabilities_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // SupernodeServiceServer is the server API for SupernodeService service. // All implementations must embed UnimplementedSupernodeServiceServer // for forward compatibility. @@ -56,6 +68,7 @@ func (c *supernodeServiceClient) GetStatus(ctx context.Context, in *StatusReques // SupernodeService provides status information for all services type SupernodeServiceServer interface { GetStatus(context.Context, *StatusRequest) (*StatusResponse, error) + GetCapabilities(context.Context, *GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) mustEmbedUnimplementedSupernodeServiceServer() } @@ -69,6 +82,9 @@ type UnimplementedSupernodeServiceServer struct{} func (UnimplementedSupernodeServiceServer) GetStatus(context.Context, *StatusRequest) (*StatusResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetStatus not implemented") } +func (UnimplementedSupernodeServiceServer) GetCapabilities(context.Context, *GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetCapabilities not implemented") +} func (UnimplementedSupernodeServiceServer) mustEmbedUnimplementedSupernodeServiceServer() {} func (UnimplementedSupernodeServiceServer) testEmbeddedByValue() {} @@ -108,6 +124,24 @@ func _SupernodeService_GetStatus_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _SupernodeService_GetCapabilities_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetCapabilitiesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SupernodeServiceServer).GetCapabilities(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SupernodeService_GetCapabilities_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SupernodeServiceServer).GetCapabilities(ctx, req.(*GetCapabilitiesRequest)) + } + return interceptor(ctx, in, info, handler) +} + // SupernodeService_ServiceDesc is the grpc.ServiceDesc for SupernodeService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -119,6 +153,10 @@ var SupernodeService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetStatus", Handler: _SupernodeService_GetStatus_Handler, }, + { + MethodName: "GetCapabilities", + Handler: _SupernodeService_GetCapabilities_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "supernode/supernode.proto", diff --git a/pkg/capabilities/compatibility_manager.go b/pkg/capabilities/compatibility_manager.go new file mode 100644 index 00000000..8182817f --- /dev/null +++ b/pkg/capabilities/compatibility_manager.go @@ -0,0 +1,121 @@ +package capabilities + +import ( + "fmt" + "strconv" + "strings" +) + +// CompatibilityManagerImpl implements the CompatibilityManager interface +type CompatibilityManagerImpl struct{} + +// NewCompatibilityManager creates a new CompatibilityManager instance +func NewCompatibilityManager() CompatibilityManager { + return &CompatibilityManagerImpl{} +} + +// CheckCompatibility compares local and peer capabilities and returns compatibility result +func (cm *CompatibilityManagerImpl) CheckCompatibility(local, peer *Capabilities) (*CompatibilityResult, error) { + if local == nil || peer == nil { + return &CompatibilityResult{ + Compatible: false, + Reason: "nil capabilities provided", + Details: map[string]interface{}{"error": "local or peer capabilities is nil"}, + }, nil + } + + // Check version compatibility + versionCompatible, err := cm.IsVersionCompatible(local.Version, peer.Version) + if err != nil { + return &CompatibilityResult{ + Compatible: false, + Reason: fmt.Sprintf("version compatibility check failed: %v", err), + Details: map[string]interface{}{"error": err.Error()}, + }, nil + } + + if !versionCompatible { + return &CompatibilityResult{ + Compatible: false, + Reason: "version incompatible", + Details: map[string]interface{}{ + "local_version": local.Version, + "peer_version": peer.Version, + }, + }, nil + } + + return &CompatibilityResult{ + Compatible: true, + Reason: "compatible", + Details: map[string]interface{}{ + "local_version": local.Version, + "peer_version": peer.Version, + }, + }, nil +} + +// IsVersionCompatible checks if two version strings are compatible using semantic versioning +func (cm *CompatibilityManagerImpl) IsVersionCompatible(localVersion, peerVersion string) (bool, error) { + localVer, err := parseVersion(localVersion) + if err != nil { + return false, fmt.Errorf("failed to parse local version %s: %w", localVersion, err) + } + + peerVer, err := parseVersion(peerVersion) + if err != nil { + return false, fmt.Errorf("failed to parse peer version %s: %w", peerVersion, err) + } + + // Compatible if major versions match + return localVer.Major == peerVer.Major, nil +} + +// HasRequiredCapabilities checks if the given capabilities include all required actions +func (cm *CompatibilityManagerImpl) HasRequiredCapabilities(caps *Capabilities, required []string) bool { + if caps == nil || caps.SupportedActions == nil { + return len(required) == 0 + } + + supportedSet := make(map[string]bool) + for _, action := range caps.SupportedActions { + supportedSet[action] = true + } + + for _, req := range required { + if !supportedSet[req] { + return false + } + } + + return true +} + +// parseVersion parses a semantic version string into VersionInfo +func parseVersion(version string) (*VersionInfo, error) { + parts := strings.Split(version, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid version format: %s", version) + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return nil, fmt.Errorf("invalid major version: %s", parts[0]) + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, fmt.Errorf("invalid minor version: %s", parts[1]) + } + + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return nil, fmt.Errorf("invalid patch version: %s", parts[2]) + } + + return &VersionInfo{ + Major: major, + Minor: minor, + Patch: patch, + }, nil +} \ No newline at end of file diff --git a/pkg/capabilities/compatibility_manager_test.go b/pkg/capabilities/compatibility_manager_test.go new file mode 100644 index 00000000..342a25cd --- /dev/null +++ b/pkg/capabilities/compatibility_manager_test.go @@ -0,0 +1,238 @@ +package capabilities + +import ( + "testing" + "time" +) + +func TestCompatibilityManager_CheckCompatibility(t *testing.T) { + cm := NewCompatibilityManager() + + tests := []struct { + name string + local *Capabilities + peer *Capabilities + expected *CompatibilityResult + wantErr bool + }{ + { + name: "nil_capabilities", + local: nil, + peer: nil, + expected: &CompatibilityResult{ + Compatible: false, + Reason: "nil capabilities provided", + }, + wantErr: false, + }, + { + name: "compatible_same_version", + local: &Capabilities{ + Version: "1.0.0", + SupportedActions: []string{"cascade"}, + Timestamp: time.Now(), + }, + peer: &Capabilities{ + Version: "1.0.0", + SupportedActions: []string{"cascade"}, + Timestamp: time.Now(), + }, + expected: &CompatibilityResult{ + Compatible: true, + Reason: "compatible", + }, + wantErr: false, + }, + { + name: "compatible_different_minor", + local: &Capabilities{ + Version: "1.0.0", + SupportedActions: []string{"cascade"}, + Timestamp: time.Now(), + }, + peer: &Capabilities{ + Version: "1.1.0", + SupportedActions: []string{"cascade"}, + Timestamp: time.Now(), + }, + expected: &CompatibilityResult{ + Compatible: true, + Reason: "compatible", + }, + wantErr: false, + }, + { + name: "incompatible_different_major", + local: &Capabilities{ + Version: "1.0.0", + SupportedActions: []string{"cascade"}, + Timestamp: time.Now(), + }, + peer: &Capabilities{ + Version: "2.0.0", + SupportedActions: []string{"cascade"}, + Timestamp: time.Now(), + }, + expected: &CompatibilityResult{ + Compatible: false, + Reason: "version incompatible", + }, + wantErr: false, + }, + { + name: "invalid_version_format", + local: &Capabilities{ + Version: "invalid", + SupportedActions: []string{"cascade"}, + Timestamp: time.Now(), + }, + peer: &Capabilities{ + Version: "1.0.0", + SupportedActions: []string{"cascade"}, + Timestamp: time.Now(), + }, + expected: &CompatibilityResult{ + Compatible: false, + Reason: "version compatibility check failed: failed to parse local version invalid: invalid version format: invalid", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := cm.CheckCompatibility(tt.local, tt.peer) + if (err != nil) != tt.wantErr { + t.Errorf("CheckCompatibility() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if result.Compatible != tt.expected.Compatible { + t.Errorf("CheckCompatibility() Compatible = %v, expected %v", result.Compatible, tt.expected.Compatible) + } + + if result.Reason != tt.expected.Reason { + t.Errorf("CheckCompatibility() Reason = %v, expected %v", result.Reason, tt.expected.Reason) + } + }) + } +} + +func TestCompatibilityManager_IsVersionCompatible(t *testing.T) { + cm := NewCompatibilityManager() + + tests := []struct { + name string + localVersion string + peerVersion string + expected bool + wantErr bool + }{ + { + name: "same_version", + localVersion: "1.0.0", + peerVersion: "1.0.0", + expected: true, + wantErr: false, + }, + { + name: "same_major_different_minor", + localVersion: "1.0.0", + peerVersion: "1.1.0", + expected: true, + wantErr: false, + }, + { + name: "different_major", + localVersion: "1.0.0", + peerVersion: "2.0.0", + expected: false, + wantErr: false, + }, + { + name: "invalid_local_version", + localVersion: "invalid", + peerVersion: "1.0.0", + expected: false, + wantErr: true, + }, + { + name: "invalid_peer_version", + localVersion: "1.0.0", + peerVersion: "invalid", + expected: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := cm.IsVersionCompatible(tt.localVersion, tt.peerVersion) + if (err != nil) != tt.wantErr { + t.Errorf("IsVersionCompatible() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if result != tt.expected { + t.Errorf("IsVersionCompatible() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestCompatibilityManager_HasRequiredCapabilities(t *testing.T) { + cm := NewCompatibilityManager() + + tests := []struct { + name string + caps *Capabilities + required []string + expected bool + }{ + { + name: "nil_capabilities_no_requirements", + caps: nil, + required: []string{}, + expected: true, + }, + { + name: "nil_capabilities_with_requirements", + caps: nil, + required: []string{"cascade"}, + expected: false, + }, + { + name: "has_all_required", + caps: &Capabilities{ + SupportedActions: []string{"cascade", "sense", "storage_challenge"}, + }, + required: []string{"cascade", "sense"}, + expected: true, + }, + { + name: "missing_required", + caps: &Capabilities{ + SupportedActions: []string{"cascade"}, + }, + required: []string{"cascade", "sense"}, + expected: false, + }, + { + name: "no_requirements", + caps: &Capabilities{ + SupportedActions: []string{"cascade"}, + }, + required: []string{}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := cm.HasRequiredCapabilities(tt.caps, tt.required) + if result != tt.expected { + t.Errorf("HasRequiredCapabilities() = %v, expected %v", result, tt.expected) + } + }) + } +} \ No newline at end of file diff --git a/pkg/capabilities/config_manager.go b/pkg/capabilities/config_manager.go new file mode 100644 index 00000000..729e5fd6 --- /dev/null +++ b/pkg/capabilities/config_manager.go @@ -0,0 +1,28 @@ +package capabilities + +import ( + "fmt" + + "github.com/spf13/viper" +) + +func LoadConfig(path string) (*CapabilityConfig, error) { + + // Configure viper to read the YAML file + v := viper.New() + v.SetConfigFile(path) + v.SetConfigType("yaml") + + // Read the configuration file + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read config file %s: %w", path, err) + } + + // Unmarshal into CapabilityConfig struct + var config CapabilityConfig + if err := v.Unmarshal(&config); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + return &config, nil +} diff --git a/pkg/capabilities/config_manager_test.go b/pkg/capabilities/config_manager_test.go new file mode 100644 index 00000000..5d94b229 --- /dev/null +++ b/pkg/capabilities/config_manager_test.go @@ -0,0 +1,160 @@ +package capabilities + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfigManager_LoadConfig(t *testing.T) { + + tests := []struct { + name string + configContent string + expectedConfig *CapabilityConfig + wantErr bool + }{ + { + name: "load_nonexistent_file", + expectedConfig: &CapabilityConfig{ + Version: "1.0.0", + SupportedActions: []string{"cascade"}, + ActionVersions: map[string][]string{ + "cascade": {"1.0.0"}, + }, + Metadata: map[string]string{ + "build_info": "default", + "features": "basic", + }, + }, + wantErr: false, + }, + { + name: "load_valid_yaml", + configContent: `version: "2.1.0" +supported_actions: + - cascade + - sense +action_versions: + cascade: + - "2.0.0" + - "2.1.0" + sense: + - "1.0.0" +metadata: + build_info: "test" + features: "advanced"`, + expectedConfig: &CapabilityConfig{ + Version: "2.1.0", + SupportedActions: []string{"cascade", "sense"}, + ActionVersions: map[string][]string{ + "cascade": {"2.0.0", "2.1.0"}, + "sense": {"1.0.0"}, + }, + Metadata: map[string]string{ + "build_info": "test", + "features": "advanced", + }, + }, + wantErr: false, + }, + { + name: "load_minimal_config", + configContent: `version: "1.0.0" +supported_actions: + - cascade +action_versions: + cascade: + - "1.0.0"`, + expectedConfig: &CapabilityConfig{ + Version: "1.0.0", + SupportedActions: []string{"cascade"}, + ActionVersions: map[string][]string{ + "cascade": {"1.0.0"}, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var configPath string + + if tt.configContent != "" { + tempDir := t.TempDir() + configPath = filepath.Join(tempDir, "test_config.yaml") + err := os.WriteFile(configPath, []byte(tt.configContent), 0644) + if err != nil { + t.Fatalf("Failed to write test config file: %v", err) + } + } else { + configPath = "/non/existent/file.yaml" + } + + config, err := LoadConfig(configPath) + if (err != nil) != tt.wantErr { + t.Errorf("LoadConfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if config == nil { + t.Fatal("LoadConfig() returned nil config") + } + + // Verify version + if config.Version != tt.expectedConfig.Version { + t.Errorf("Expected version '%s', got '%s'", tt.expectedConfig.Version, config.Version) + } + + // Verify supported actions + if len(config.SupportedActions) != len(tt.expectedConfig.SupportedActions) { + t.Errorf("Expected %d supported actions, got %d", len(tt.expectedConfig.SupportedActions), len(config.SupportedActions)) + } else { + for i, expected := range tt.expectedConfig.SupportedActions { + if config.SupportedActions[i] != expected { + t.Errorf("Expected action '%s' at index %d, got '%s'", expected, i, config.SupportedActions[i]) + } + } + } + + // Verify action versions + if len(config.ActionVersions) != len(tt.expectedConfig.ActionVersions) { + t.Errorf("Expected %d action versions, got %d", len(tt.expectedConfig.ActionVersions), len(config.ActionVersions)) + } else { + for action, expectedVersions := range tt.expectedConfig.ActionVersions { + actualVersions, exists := config.ActionVersions[action] + if !exists { + t.Errorf("Expected action versions for '%s' to exist", action) + continue + } + if len(actualVersions) != len(expectedVersions) { + t.Errorf("Expected %d versions for action '%s', got %d", len(expectedVersions), action, len(actualVersions)) + } else { + for i, expected := range expectedVersions { + if actualVersions[i] != expected { + t.Errorf("Expected version '%s' for action '%s' at index %d, got '%s'", expected, action, i, actualVersions[i]) + } + } + } + } + } + + // Verify metadata if expected + if tt.expectedConfig.Metadata != nil { + if config.Metadata == nil { + t.Error("Expected metadata to exist") + } else { + for key, expectedValue := range tt.expectedConfig.Metadata { + actualValue, exists := config.Metadata[key] + if !exists { + t.Errorf("Expected metadata key '%s' to exist", key) + } else if actualValue != expectedValue { + t.Errorf("Expected metadata '%s' = '%s', got '%s'", key, expectedValue, actualValue) + } + } + } + } + }) + } +} \ No newline at end of file diff --git a/pkg/capabilities/interfaces.go b/pkg/capabilities/interfaces.go new file mode 100644 index 00000000..f32bb387 --- /dev/null +++ b/pkg/capabilities/interfaces.go @@ -0,0 +1,13 @@ +package capabilities + +// CompatibilityManager handles version comparison and compatibility checking between supernodes +type CompatibilityManager interface { + // CheckCompatibility compares local and peer capabilities and returns compatibility result + CheckCompatibility(local, peer *Capabilities) (*CompatibilityResult, error) + + // IsVersionCompatible checks if two version strings are compatible using semantic versioning + IsVersionCompatible(localVersion, peerVersion string) (bool, error) + + // HasRequiredCapabilities checks if the given capabilities include all required actions + HasRequiredCapabilities(caps *Capabilities, required []string) bool +} diff --git a/pkg/capabilities/peer_compatibility.go b/pkg/capabilities/peer_compatibility.go new file mode 100644 index 00000000..807ccf47 --- /dev/null +++ b/pkg/capabilities/peer_compatibility.go @@ -0,0 +1,95 @@ +package capabilities + +import ( + "context" + "fmt" + "time" + + pb "github.com/LumeraProtocol/supernode/gen/supernode" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// PeerCompatibilityChecker handles compatibility verification with remote peers +type PeerCompatibilityChecker struct { + localCapabilities *Capabilities + compatManager CompatibilityManager +} + +// NewPeerCompatibilityChecker creates a new peer compatibility checker +func NewPeerCompatibilityChecker(localCaps *Capabilities) *PeerCompatibilityChecker { + return &PeerCompatibilityChecker{ + localCapabilities: localCaps, + compatManager: NewCompatibilityManager(), + } +} + +// CheckPeerCompatibility checks if a peer is compatible with this supernode +func (pcc *PeerCompatibilityChecker) CheckPeerCompatibility(ctx context.Context, peerAddr string) (*CompatibilityResult, error) { + // Connect to peer + conn, err := grpc.Dial(peerAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return &CompatibilityResult{ + Compatible: false, + Reason: fmt.Sprintf("failed to connect to peer: %v", err), + Details: map[string]interface{}{"error": err.Error()}, + }, nil + } + defer conn.Close() + + // Query peer capabilities + client := pb.NewSupernodeServiceClient(conn) + ctxTimeout, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + resp, err := client.GetCapabilities(ctxTimeout, &pb.GetCapabilitiesRequest{}) + if err != nil { + return &CompatibilityResult{ + Compatible: false, + Reason: fmt.Sprintf("failed to query peer capabilities: %v", err), + Details: map[string]interface{}{"error": err.Error()}, + }, nil + } + + caps := resp.GetCapabilities() + if caps == nil { + return &CompatibilityResult{ + Compatible: false, + Reason: "peer returned empty capabilities", + Details: map[string]interface{}{"peer_addr": peerAddr}, + }, nil + } + + // Convert protobuf capabilities to local format + peerCaps := &Capabilities{ + Version: caps.Version, + SupportedActions: caps.SupportedActions, + ActionVersions: make(map[string][]string), + Metadata: caps.Metadata, + Timestamp: time.Unix(caps.Timestamp, 0), + } + + // Convert action versions + for action, actionVersions := range caps.ActionVersions { + if actionVersions != nil { + peerCaps.ActionVersions[action] = actionVersions.Versions + } + } + + // Check compatibility + return pcc.compatManager.CheckCompatibility(pcc.localCapabilities, peerCaps) +} + +// CheckPeerCompatibilityWithFallback checks peer compatibility with graceful fallback +func (pcc *PeerCompatibilityChecker) CheckPeerCompatibilityWithFallback(ctx context.Context, peerAddr string) *CompatibilityResult { + result, err := pcc.CheckPeerCompatibility(ctx, peerAddr) + if err != nil { + // If we can't check compatibility, assume compatible for backward compatibility + return &CompatibilityResult{ + Compatible: true, + Reason: fmt.Sprintf("compatibility check failed, assuming compatible: %v", err), + Details: map[string]interface{}{"fallback": true, "error": err.Error()}, + } + } + return result +} \ No newline at end of file diff --git a/pkg/capabilities/service.go b/pkg/capabilities/service.go new file mode 100644 index 00000000..a3e5e2df --- /dev/null +++ b/pkg/capabilities/service.go @@ -0,0 +1,37 @@ +package capabilities + +import ( + "time" +) + +// LoadCapabilitiesFromConfig loads capabilities from a configuration file +func LoadCapabilitiesFromConfig(configPath string) (*Capabilities, error) { + config, err := LoadConfig(configPath) + if err != nil { + return nil, err + } + + return &Capabilities{ + Version: config.Version, + SupportedActions: config.SupportedActions, + ActionVersions: config.ActionVersions, + Metadata: config.Metadata, + Timestamp: time.Now(), + }, nil +} + +// CreateDefaultCapabilities creates default capabilities when no config is available +func CreateDefaultCapabilities() *Capabilities { + return &Capabilities{ + Version: "1.0.0", + SupportedActions: []string{"cascade"}, + ActionVersions: map[string][]string{ + "cascade": {"1.0.0"}, + }, + Metadata: map[string]string{ + "build_info": "default", + "features": "basic", + }, + Timestamp: time.Now(), + } +} \ No newline at end of file diff --git a/pkg/capabilities/test_config.yaml b/pkg/capabilities/test_config.yaml new file mode 100644 index 00000000..bb0066be --- /dev/null +++ b/pkg/capabilities/test_config.yaml @@ -0,0 +1,13 @@ +version: "2.1.0" +supported_actions: + - cascade + - sense +action_versions: + cascade: + - "2.0.0" + - "2.1.0" + sense: + - "1.0.0" +metadata: + build_info: "test" + features: "advanced" \ No newline at end of file diff --git a/pkg/capabilities/types.go b/pkg/capabilities/types.go new file mode 100644 index 00000000..b6eda67a --- /dev/null +++ b/pkg/capabilities/types.go @@ -0,0 +1,36 @@ +package capabilities + +import ( + "time" +) + +// Capabilities represents the runtime capabilities of a supernode +type Capabilities struct { + Version string `json:"version"` + SupportedActions []string `json:"supported_actions"` + ActionVersions map[string][]string `json:"action_versions"` + Metadata map[string]string `json:"metadata,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// CapabilityConfig represents the YAML configuration structure for capabilities +type CapabilityConfig struct { + Version string `yaml:"version"` + SupportedActions []string `yaml:"supported_actions"` + ActionVersions map[string][]string `yaml:"action_versions"` + Metadata map[string]string `yaml:"metadata,omitempty"` +} + +// CompatibilityResult represents the result of a compatibility check between two supernodes +type CompatibilityResult struct { + Compatible bool `json:"compatible"` + Reason string `json:"reason"` + Details map[string]interface{} `json:"details"` +} + +// VersionInfo represents parsed semantic version information +type VersionInfo struct { + Major int `json:"major"` + Minor int `json:"minor"` + Patch int `json:"patch"` +} \ No newline at end of file diff --git a/proto/supernode/supernode.proto b/proto/supernode/supernode.proto index 10d72990..5dc83bd9 100644 --- a/proto/supernode/supernode.proto +++ b/proto/supernode/supernode.proto @@ -5,6 +5,7 @@ option go_package = "github.com/LumeraProtocol/supernode/gen/supernode"; // SupernodeService provides status information for all services service SupernodeService { rpc GetStatus(StatusRequest) returns (StatusResponse); + rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); } message StatusRequest {} @@ -34,4 +35,22 @@ message StatusResponse { Memory memory = 2; repeated ServiceTasks services = 3; repeated string available_services = 4; +} + +message GetCapabilitiesRequest {} + +message GetCapabilitiesResponse { + Capabilities capabilities = 1; +} + +message Capabilities { + string version = 1; + repeated string supported_actions = 2; + map action_versions = 3; + map metadata = 4; + int64 timestamp = 5; +} + +message ActionVersions { + repeated string versions = 1; } \ No newline at end of file diff --git a/supernode/cmd/capabilities.go b/supernode/cmd/capabilities.go new file mode 100644 index 00000000..c2eaa8c9 --- /dev/null +++ b/supernode/cmd/capabilities.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/LumeraProtocol/supernode/pkg/capabilities" + pb "github.com/LumeraProtocol/supernode/gen/supernode" + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +var capabilitiesCmd = &cobra.Command{ + Use: "capabilities", + Short: "Manage and query supernode capabilities", + Long: `Commands for managing and querying supernode capabilities including version information and supported actions.`, +} + +var capabilitiesShowCmd = &cobra.Command{ + Use: "show", + Short: "Display local capabilities", + Long: `Display the local supernode's capabilities including version, supported actions, and action versions.`, + RunE: func(cmd *cobra.Command, args []string) error { + // Try to load capabilities from config + capConfigPath := filepath.Join(baseDir, "capabilities.yaml") + caps, err := capabilities.LoadCapabilitiesFromConfig(capConfigPath) + if err != nil { + // Fall back to default capabilities + caps = capabilities.CreateDefaultCapabilities() + fmt.Printf("Note: Using default capabilities (config not found at %s)\n\n", capConfigPath) + } + + // Display capabilities + fmt.Printf("Supernode Capabilities:\n") + fmt.Printf(" Version: %s\n", caps.Version) + fmt.Printf(" Supported Actions: %s\n", strings.Join(caps.SupportedActions, ", ")) + + if len(caps.ActionVersions) > 0 { + fmt.Printf(" Action Versions:\n") + for action, versions := range caps.ActionVersions { + fmt.Printf(" %s: %s\n", action, strings.Join(versions, ", ")) + } + } + + if len(caps.Metadata) > 0 { + fmt.Printf(" Metadata:\n") + for key, value := range caps.Metadata { + fmt.Printf(" %s: %s\n", key, value) + } + } + + fmt.Printf(" Timestamp: %s\n", caps.Timestamp.Format(time.RFC3339)) + + return nil + }, +} + +var capabilitiesQueryCmd = &cobra.Command{ + Use: "query [peer-address]", + Short: "Query peer capabilities", + Long: `Query the capabilities of a remote supernode peer including version details and supported actions.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + peerAddr := args[0] + + // Connect to peer + conn, err := grpc.Dial(peerAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("failed to connect to peer %s: %w", peerAddr, err) + } + defer conn.Close() + + // Create client and query capabilities + client := pb.NewSupernodeServiceClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := client.GetCapabilities(ctx, &pb.GetCapabilitiesRequest{}) + if err != nil { + return fmt.Errorf("failed to query capabilities from peer %s: %w", peerAddr, err) + } + + caps := resp.GetCapabilities() + if caps == nil { + return fmt.Errorf("peer returned empty capabilities") + } + + // Display peer capabilities + fmt.Printf("Peer Capabilities (%s):\n", peerAddr) + fmt.Printf(" Version: %s\n", caps.Version) + fmt.Printf(" Supported Actions: %s\n", strings.Join(caps.SupportedActions, ", ")) + + if len(caps.ActionVersions) > 0 { + fmt.Printf(" Action Versions:\n") + for action, actionVersions := range caps.ActionVersions { + fmt.Printf(" %s: %s\n", action, strings.Join(actionVersions.Versions, ", ")) + } + } + + if len(caps.Metadata) > 0 { + fmt.Printf(" Metadata:\n") + for key, value := range caps.Metadata { + fmt.Printf(" %s: %s\n", key, value) + } + } + + fmt.Printf(" Timestamp: %s\n", time.Unix(caps.Timestamp, 0).Format(time.RFC3339)) + + return nil + }, +} + +func init() { + capabilitiesCmd.AddCommand(capabilitiesShowCmd) + capabilitiesCmd.AddCommand(capabilitiesQueryCmd) + rootCmd.AddCommand(capabilitiesCmd) +} \ No newline at end of file diff --git a/supernode/cmd/start.go b/supernode/cmd/start.go index e7e1511c..2a461253 100644 --- a/supernode/cmd/start.go +++ b/supernode/cmd/start.go @@ -5,11 +5,13 @@ import ( "fmt" "os" "os/signal" + "path/filepath" "syscall" "github.com/LumeraProtocol/supernode/p2p" "github.com/LumeraProtocol/supernode/p2p/kademlia/store/cloud.go" "github.com/LumeraProtocol/supernode/p2p/kademlia/store/sqlite" + "github.com/LumeraProtocol/supernode/pkg/capabilities" "github.com/LumeraProtocol/supernode/pkg/codec" "github.com/LumeraProtocol/supernode/pkg/keyring" "github.com/LumeraProtocol/supernode/pkg/logtrace" @@ -101,10 +103,19 @@ The supernode will connect to the Lumera network and begin participating in the // Create cascade action server cascadeActionServer := cascade.NewCascadeActionServer(cService) + // Load capabilities + capConfigPath := filepath.Join(baseDir, "capabilities.yaml") + caps, err := capabilities.LoadCapabilitiesFromConfig(capConfigPath) + if err != nil { + logtrace.Warn(ctx, "Failed to load capabilities config, using defaults", logtrace.Fields{"error": err.Error(), "config_path": capConfigPath}) + caps = capabilities.CreateDefaultCapabilities() + } + logtrace.Info(ctx, "Loaded capabilities", logtrace.Fields{"version": caps.Version, "actions": caps.SupportedActions}) + // Create supernode status service statusService := supernodeService.NewSupernodeStatusService() statusService.RegisterTaskProvider(cService) - supernodeServer := server.NewSupernodeServer(statusService) + supernodeServer := server.NewSupernodeServer(statusService, caps) // Configure server serverConfig := &server.Config{ diff --git a/supernode/node/supernode/server/status_server.go b/supernode/node/supernode/server/status_server.go index 91e172a4..59efa589 100644 --- a/supernode/node/supernode/server/status_server.go +++ b/supernode/node/supernode/server/status_server.go @@ -4,8 +4,10 @@ import ( "context" "google.golang.org/grpc" + "google.golang.org/grpc/codes" pb "github.com/LumeraProtocol/supernode/gen/supernode" + "github.com/LumeraProtocol/supernode/pkg/capabilities" "github.com/LumeraProtocol/supernode/supernode/services/common/supernode" ) @@ -13,12 +15,14 @@ import ( type SupernodeServer struct { pb.UnimplementedSupernodeServiceServer statusService *supernode.SupernodeStatusService + capabilities *capabilities.Capabilities } // NewSupernodeServer creates a new SupernodeServer -func NewSupernodeServer(statusService *supernode.SupernodeStatusService) *SupernodeServer { +func NewSupernodeServer(statusService *supernode.SupernodeStatusService, caps *capabilities.Capabilities) *SupernodeServer { return &SupernodeServer{ statusService: statusService, + capabilities: caps, } } @@ -59,6 +63,34 @@ func (s *SupernodeServer) GetStatus(ctx context.Context, req *pb.StatusRequest) return response, nil } +// GetCapabilities implements SupernodeService.GetCapabilities +func (s *SupernodeServer) GetCapabilities(ctx context.Context, req *pb.GetCapabilitiesRequest) (*pb.GetCapabilitiesResponse, error) { + if s.capabilities == nil { + return nil, grpc.Errorf(codes.Internal, "capabilities not initialized") + } + + // Convert action versions to protobuf format + actionVersions := make(map[string]*pb.ActionVersions) + for action, versions := range s.capabilities.ActionVersions { + actionVersions[action] = &pb.ActionVersions{ + Versions: versions, + } + } + + // Create protobuf capabilities + pbCapabilities := &pb.Capabilities{ + Version: s.capabilities.Version, + SupportedActions: s.capabilities.SupportedActions, + ActionVersions: actionVersions, + Metadata: s.capabilities.Metadata, + Timestamp: s.capabilities.Timestamp.Unix(), + } + + return &pb.GetCapabilitiesResponse{ + Capabilities: pbCapabilities, + }, nil +} + // Desc implements the service interface for gRPC service registration func (s *SupernodeServer) Desc() *grpc.ServiceDesc { return &pb.SupernodeService_ServiceDesc diff --git a/supernode/node/supernode/server/status_server_test.go b/supernode/node/supernode/server/status_server_test.go index 99f3db1b..c5f0c344 100644 --- a/supernode/node/supernode/server/status_server_test.go +++ b/supernode/node/supernode/server/status_server_test.go @@ -3,11 +3,13 @@ package server import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" pb "github.com/LumeraProtocol/supernode/gen/supernode" + "github.com/LumeraProtocol/supernode/pkg/capabilities" "github.com/LumeraProtocol/supernode/supernode/services/common" "github.com/LumeraProtocol/supernode/supernode/services/common/supernode" ) @@ -18,8 +20,21 @@ func TestSupernodeServer_GetStatus(t *testing.T) { // Create status service statusService := supernode.NewSupernodeStatusService() + // Create test capabilities + caps := &capabilities.Capabilities{ + Version: "1.0.0", + SupportedActions: []string{"cascade"}, + ActionVersions: map[string][]string{ + "cascade": {"1.0.0"}, + }, + Metadata: map[string]string{ + "test": "true", + }, + Timestamp: time.Now(), + } + // Create server - server := NewSupernodeServer(statusService) + server := NewSupernodeServer(statusService, caps) // Test with empty service resp, err := server.GetStatus(ctx, &pb.StatusRequest{}) @@ -51,8 +66,21 @@ func TestSupernodeServer_GetStatusWithService(t *testing.T) { } statusService.RegisterTaskProvider(mockProvider) + // Create test capabilities + caps := &capabilities.Capabilities{ + Version: "1.0.0", + SupportedActions: []string{"cascade"}, + ActionVersions: map[string][]string{ + "cascade": {"1.0.0"}, + }, + Metadata: map[string]string{ + "test": "true", + }, + Timestamp: time.Now(), + } + // Create server - server := NewSupernodeServer(statusService) + server := NewSupernodeServer(statusService, caps) // Test with service resp, err := server.GetStatus(ctx, &pb.StatusRequest{}) @@ -73,9 +101,80 @@ func TestSupernodeServer_GetStatusWithService(t *testing.T) { func TestSupernodeServer_Desc(t *testing.T) { statusService := supernode.NewSupernodeStatusService() - server := NewSupernodeServer(statusService) + caps := &capabilities.Capabilities{ + Version: "1.0.0", + SupportedActions: []string{"cascade"}, + ActionVersions: map[string][]string{ + "cascade": {"1.0.0"}, + }, + Metadata: map[string]string{ + "test": "true", + }, + Timestamp: time.Now(), + } + server := NewSupernodeServer(statusService, caps) desc := server.Desc() assert.NotNil(t, desc) assert.Equal(t, "supernode.SupernodeService", desc.ServiceName) } + +func TestSupernodeServer_GetCapabilities(t *testing.T) { + ctx := context.Background() + + // Create status service + statusService := supernode.NewSupernodeStatusService() + + // Create test capabilities + caps := &capabilities.Capabilities{ + Version: "1.2.3", + SupportedActions: []string{"cascade", "sense"}, + ActionVersions: map[string][]string{ + "cascade": {"1.0.0", "1.1.0"}, + "sense": {"2.0.0"}, + }, + Metadata: map[string]string{ + "build_info": "test-build", + "features": "advanced", + }, + Timestamp: time.Now(), + } + + // Create server + server := NewSupernodeServer(statusService, caps) + + // Test GetCapabilities + resp, err := server.GetCapabilities(ctx, &pb.GetCapabilitiesRequest{}) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.NotNil(t, resp.Capabilities) + + // Check capabilities content + respCaps := resp.Capabilities + assert.Equal(t, "1.2.3", respCaps.Version) + assert.Equal(t, []string{"cascade", "sense"}, respCaps.SupportedActions) + assert.Equal(t, "test-build", respCaps.Metadata["build_info"]) + assert.Equal(t, "advanced", respCaps.Metadata["features"]) + assert.True(t, respCaps.Timestamp > 0) + + // Check action versions + assert.Len(t, respCaps.ActionVersions, 2) + assert.Equal(t, []string{"1.0.0", "1.1.0"}, respCaps.ActionVersions["cascade"].Versions) + assert.Equal(t, []string{"2.0.0"}, respCaps.ActionVersions["sense"].Versions) +} + +func TestSupernodeServer_GetCapabilities_NilCapabilities(t *testing.T) { + ctx := context.Background() + + // Create status service + statusService := supernode.NewSupernodeStatusService() + + // Create server with nil capabilities + server := NewSupernodeServer(statusService, nil) + + // Test GetCapabilities with nil capabilities + resp, err := server.GetCapabilities(ctx, &pb.GetCapabilitiesRequest{}) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "capabilities not initialized") +}