diff --git a/cmd/weaver/commands/block/node/check.go b/cmd/weaver/commands/block/node/check.go index c6d12927..fafc428c 100644 --- a/cmd/weaver/commands/block/node/check.go +++ b/cmd/weaver/commands/block/node/check.go @@ -7,6 +7,7 @@ import ( "github.com/hashgraph/solo-weaver/cmd/weaver/commands/common" "github.com/hashgraph/solo-weaver/internal/config" "github.com/hashgraph/solo-weaver/internal/workflows" + "github.com/hashgraph/solo-weaver/pkg/hardware" "github.com/joomcode/errorx" "github.com/spf13/cobra" ) @@ -25,6 +26,12 @@ var checkCmd = &cobra.Command{ return errorx.IllegalArgument.New("profile flag is required") } + // Validate profile early for better error messages + if !hardware.IsValidProfile(flagProfile) { + return errorx.IllegalArgument.New("unsupported profile: %q. Supported profiles: %v", + flagProfile, hardware.SupportedProfiles()) + } + // Set the profile in the global config so other components can access it config.SetProfile(flagProfile) diff --git a/cmd/weaver/commands/block/node/install.go b/cmd/weaver/commands/block/node/install.go index 8c7fcf8c..e751fce2 100644 --- a/cmd/weaver/commands/block/node/install.go +++ b/cmd/weaver/commands/block/node/install.go @@ -7,6 +7,7 @@ import ( "github.com/hashgraph/solo-weaver/cmd/weaver/commands/common" "github.com/hashgraph/solo-weaver/internal/config" "github.com/hashgraph/solo-weaver/internal/workflows" + "github.com/hashgraph/solo-weaver/pkg/hardware" "github.com/hashgraph/solo-weaver/pkg/sanity" "github.com/joomcode/errorx" "github.com/spf13/cobra" @@ -27,6 +28,12 @@ var installCmd = &cobra.Command{ return errorx.IllegalArgument.New("profile flag is required") } + // Validate profile early for better error messages + if !hardware.IsValidProfile(flagProfile) { + return errorx.IllegalArgument.New("unsupported profile: %q. Supported profiles: %v", + flagProfile, hardware.SupportedProfiles()) + } + // Set the profile in the global config so other components can access it config.SetProfile(flagProfile) diff --git a/cmd/weaver/commands/block/node/reset.go b/cmd/weaver/commands/block/node/reset.go index b927b110..885114dd 100644 --- a/cmd/weaver/commands/block/node/reset.go +++ b/cmd/weaver/commands/block/node/reset.go @@ -7,6 +7,7 @@ import ( "github.com/hashgraph/solo-weaver/cmd/weaver/commands/common" "github.com/hashgraph/solo-weaver/internal/config" "github.com/hashgraph/solo-weaver/internal/workflows" + "github.com/hashgraph/solo-weaver/pkg/hardware" "github.com/joomcode/errorx" "github.com/spf13/cobra" ) @@ -30,8 +31,13 @@ WARNING: This operation is destructive and cannot be undone. All block data will return errorx.IllegalArgument.Wrap(err, "failed to get profile flag") } - // Set the profile in the global config if provided + // Validate profile early if provided if flagProfile != "" { + if !hardware.IsValidProfile(flagProfile) { + return errorx.IllegalArgument.New("unsupported profile: %q. Supported profiles: %v", + flagProfile, hardware.SupportedProfiles()) + } + // Set the profile in the global config config.SetProfile(flagProfile) } diff --git a/cmd/weaver/commands/kube/cluster/install.go b/cmd/weaver/commands/kube/cluster/install.go index 636b6adf..76ffe587 100644 --- a/cmd/weaver/commands/kube/cluster/install.go +++ b/cmd/weaver/commands/kube/cluster/install.go @@ -7,6 +7,7 @@ import ( "github.com/hashgraph/solo-weaver/cmd/weaver/commands/common" "github.com/hashgraph/solo-weaver/internal/config" "github.com/hashgraph/solo-weaver/internal/workflows" + "github.com/hashgraph/solo-weaver/pkg/hardware" "github.com/joomcode/errorx" "github.com/spf13/cobra" ) @@ -25,6 +26,17 @@ var installCmd = &cobra.Command{ return errorx.IllegalArgument.New("profile flag is required") } + // Validate node type and profile early for better error messages + if !hardware.IsValidNodeType(flagNodeType) { + return errorx.IllegalArgument.New("unsupported node type: %q. Supported types: %v", + flagNodeType, hardware.SupportedNodeTypes()) + } + + if !hardware.IsValidProfile(flagProfile) { + return errorx.IllegalArgument.New("unsupported profile: %q. Supported profiles: %v", + flagProfile, hardware.SupportedProfiles()) + } + // Set the profile in the global config so other components can access it config.SetProfile(flagProfile) diff --git a/docs/quickstart.md b/docs/quickstart.md index c0c9126e..1aa7ea28 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -55,13 +55,14 @@ Most installation commands support these execution control flags: ## Deployment Profiles -Solo Provisioner supports four deployment profiles that configure behavior and defaults: +Solo Provisioner supports five deployment profiles that configure behavior and defaults: | Profile | Description | Use Case | |---------|-------------|----------| | `local` | Local development and testing | Development, CI/CD | | `perfnet` | Performance testing network | Load testing | | `testnet` | Hedera Testnet | Integration testing | +| `previewnet` | Hedera Previewnet | Preview/staging testing | | `mainnet` | Hedera Mainnet | Production deployment | > **Important**: Always use `--profile` to specify your target environment. diff --git a/internal/core/const.go b/internal/core/const.go index 88a01906..fdd6011a 100644 --- a/internal/core/const.go +++ b/internal/core/const.go @@ -27,16 +27,18 @@ const ( NodeTypeRelay = "relay" // Deployment profiles - ProfileLocal = "local" - ProfilePerfnet = "perfnet" - ProfileTestnet = "testnet" - ProfileMainnet = "mainnet" + ProfileLocal = "local" + ProfilePerfnet = "perfnet" + ProfileTestnet = "testnet" + ProfilePreviewnet = "previewnet" + ProfileMainnet = "mainnet" ) var allProfiles = []string{ ProfileLocal, ProfilePerfnet, ProfileTestnet, + ProfilePreviewnet, ProfileMainnet, } diff --git a/internal/workflows/preflight.go b/internal/workflows/preflight.go index 72138967..6f876bcf 100644 --- a/internal/workflows/preflight.go +++ b/internal/workflows/preflight.go @@ -36,7 +36,7 @@ func CheckHostProfileStep(nodeType string, profile string) automa.Builder { if !hardware.IsValidNodeType(nodeType) { return automa.FailureReport(stp, automa.WithError( - errorx.IllegalArgument.New("unsupported node type: %s. Supported types: %v", + errorx.IllegalArgument.New("unsupported node type: %q. Supported types: %v", nodeType, hardware.SupportedNodeTypes()))) } diff --git a/pkg/hardware/base_node.go b/pkg/hardware/base_node.go index d1f97426..be2df863 100644 --- a/pkg/hardware/base_node.go +++ b/pkg/hardware/base_node.go @@ -4,6 +4,7 @@ package hardware import ( "fmt" + "strings" ) const ( @@ -71,11 +72,46 @@ func (b *baseNode) ValidateMemory() error { return nil } -// ValidateStorage validates storage requirements using common logic +// ValidateStorage validates storage requirements using common logic. +// If MinSSDStorageGB or MinHDDStorageGB are set, validates those separately. +// Otherwise, validates total storage against MinStorageGB. func (b *baseNode) ValidateStorage() error { + // Check if we need to validate SSD/HDD separately + if b.minimalRequirements.MinSSDStorageGB > 0 || b.minimalRequirements.MinHDDStorageGB > 0 { + return b.validateSplitStorage() + } + + // Default: validate total storage totalStorageGB := b.actualHostProfile.GetTotalStorageGB() if int(totalStorageGB) < b.minimalRequirements.MinStorageGB { - return fmt.Errorf("storage does not meet %s requirements (minimum %d GB)", b.nodeType, b.minimalRequirements.MinStorageGB) + return fmt.Errorf("storage does not meet %s requirements (minimum %d GB, found %d GB)", + b.nodeType, b.minimalRequirements.MinStorageGB, totalStorageGB) + } + return nil +} + +// validateSplitStorage validates SSD and HDD storage separately +func (b *baseNode) validateSplitStorage() error { + var errs []string + + if b.minimalRequirements.MinSSDStorageGB > 0 { + ssdStorageGB := b.actualHostProfile.GetSSDStorageGB() + if int(ssdStorageGB) < b.minimalRequirements.MinSSDStorageGB { + errs = append(errs, fmt.Sprintf("SSD/NVMe storage: minimum %d GB required, found %d GB", + b.minimalRequirements.MinSSDStorageGB, ssdStorageGB)) + } + } + + if b.minimalRequirements.MinHDDStorageGB > 0 { + hddStorageGB := b.actualHostProfile.GetHDDStorageGB() + if int(hddStorageGB) < b.minimalRequirements.MinHDDStorageGB { + errs = append(errs, fmt.Sprintf("HDD storage: minimum %d GB required, found %d GB", + b.minimalRequirements.MinHDDStorageGB, hddStorageGB)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("storage does not meet %s requirements: %s", b.nodeType, strings.Join(errs, "; ")) } return nil } diff --git a/pkg/hardware/block_node.go b/pkg/hardware/block_node.go deleted file mode 100644 index 2a71a162..00000000 --- a/pkg/hardware/block_node.go +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package hardware - -// blockNode represents a block node with its specific requirements and validation logic -type blockNode struct { - baseNode -} - -// Ensure blockNode implements Spec -var _ Spec = (*blockNode)(nil) - -// NewBlockNodeSpec creates a new block node specification checker with SystemInfo interface -func NewBlockNodeSpec(hostProfile HostProfile) Spec { - return &blockNode{ - baseNode: baseNode{ - nodeType: "Block Node", - actualHostProfile: hostProfile, - minimalRequirements: BaselineRequirements{ - MinCpuCores: 8, - MinMemoryGB: 16, - MinStorageGB: 5000, - MinSupportedOS: []string{"Ubuntu 18", "Debian 10"}, - }, - }, - } -} diff --git a/pkg/hardware/consensus_node.go b/pkg/hardware/consensus_node.go deleted file mode 100644 index 0aa6065f..00000000 --- a/pkg/hardware/consensus_node.go +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package hardware - -// consensusNode represents a consensus node with its specific requirements and validation logic -type consensusNode struct { - baseNode -} - -// Ensure consensusNode implements Spec -var _ Spec = (*consensusNode)(nil) - -// NewConsensusNodeSpec creates a new consensus node specification checker with SystemInfo interface -func NewConsensusNodeSpec(hostProfile HostProfile) Spec { - return &consensusNode{ - baseNode: baseNode{ - nodeType: "Consensus Node", - actualHostProfile: hostProfile, - minimalRequirements: BaselineRequirements{ - MinCpuCores: 16, - MinMemoryGB: 32, - MinStorageGB: 1000, - MinSupportedOS: []string{"Ubuntu 20", "Debian 11"}, - }, - }, - } -} diff --git a/pkg/hardware/factory.go b/pkg/hardware/factory.go index 668a92f4..d34d26a1 100644 --- a/pkg/hardware/factory.go +++ b/pkg/hardware/factory.go @@ -16,7 +16,7 @@ func SupportedNodeTypes() []string { // SupportedProfiles returns all supported deployment profiles func SupportedProfiles() []string { - return []string{core.ProfileLocal, core.ProfilePerfnet, core.ProfileTestnet, core.ProfileMainnet} + return []string{core.ProfileLocal, core.ProfilePerfnet, core.ProfileTestnet, core.ProfilePreviewnet, core.ProfileMainnet} } // IsValidNodeType checks if the given node type is supported @@ -41,27 +41,29 @@ func IsValidProfile(profile string) bool { return false } -// CreateNodeSpec creates the appropriate node spec based on node type, profile and host profile +// CreateNodeSpec creates the appropriate node spec based on node type, profile and host profile. +// This function uses a requirements registry that maps (nodeType, profile) combinations +// to their specific hardware requirements, properly separating the concerns of +// node type (what kind of node) and profile (deployment environment). func CreateNodeSpec(nodeType string, profile string, hostProfile HostProfile) (Spec, error) { - normalized := strings.ToLower(nodeType) + normalizedNodeType := strings.ToLower(nodeType) normalizedProfile := strings.ToLower(profile) - // For local profile, use local node specs regardless of node type - if normalizedProfile == core.ProfileLocal { - return NewLocalNodeSpec(hostProfile), nil + // Validate node type + if !IsValidNodeType(normalizedNodeType) { + return nil, errorx.IllegalArgument.New("unsupported node type: %q. Supported types: %v", nodeType, SupportedNodeTypes()) } - // For other profiles, use node-specific requirements - switch normalized { - case core.NodeTypeBlock: - return NewBlockNodeSpec(hostProfile), nil - case core.NodeTypeConsensus: - return NewConsensusNodeSpec(hostProfile), nil - default: - supportedTypes := make([]string, len(SupportedNodeTypes())) - for i, t := range SupportedNodeTypes() { - supportedTypes[i] = string(t) - } - return nil, errorx.IllegalArgument.New("unsupported node type: %s. Supported types: %v", nodeType, supportedTypes) + // Validate profile + if !IsValidProfile(normalizedProfile) { + return nil, errorx.IllegalArgument.New("unsupported profile: %q. Supported profiles: %v", profile, SupportedProfiles()) } + + // Use the new unified node spec that looks up requirements from the registry + spec, err := NewNodeSpec(normalizedNodeType, normalizedProfile, hostProfile) + if err != nil { + return nil, errorx.IllegalArgument.Wrap(err, "failed to create node spec") + } + + return spec, nil } diff --git a/pkg/hardware/hardware_test.go b/pkg/hardware/hardware_test.go index 50524cff..c3078082 100644 --- a/pkg/hardware/hardware_test.go +++ b/pkg/hardware/hardware_test.go @@ -4,6 +4,8 @@ package hardware import ( "testing" + + "github.com/hashgraph/solo-weaver/internal/core" ) // MockHostProfile is a testable implementation of HostProfile @@ -14,7 +16,9 @@ type MockHostProfile struct { TotalMemoryGB uint64 AvailableMemoryGB uint64 TotalStorageGB uint64 - NodeRunning bool // Add this field to control the mock behavior + SSDStorageGB uint64 + HDDStorageGB uint64 + NodeRunning bool } // NewMockHostProfile creates a new MockHostProfile for testing @@ -29,7 +33,26 @@ func NewMockHostProfile(osVendor, osVersion string, cpuCores uint, memoryGB uint TotalMemoryGB: memoryGB, AvailableMemoryGB: availableMemoryGB, TotalStorageGB: storageGB, - NodeRunning: false, // Default to false + SSDStorageGB: storageGB, // Default: all storage is SSD + HDDStorageGB: 0, + NodeRunning: false, + } +} + +// NewMockHostProfileWithStorage creates a MockHostProfile with explicit SSD/HDD storage +func NewMockHostProfileWithStorage(osVendor, osVersion string, cpuCores uint, memoryGB, ssdGB, hddGB uint64) HostProfile { + availableMemoryGB := uint64(float64(memoryGB) * 0.8) + + return &MockHostProfile{ + OSVendor: osVendor, + OSVersion: osVersion, + CPUCores: cpuCores, + TotalMemoryGB: memoryGB, + AvailableMemoryGB: availableMemoryGB, + TotalStorageGB: ssdGB + hddGB, + SSDStorageGB: ssdGB, + HDDStorageGB: hddGB, + NodeRunning: false, } } @@ -46,6 +69,8 @@ func (m *MockHostProfile) GetCPUCores() uint { return m.CPUCores } func (m *MockHostProfile) GetTotalMemoryGB() uint64 { return m.TotalMemoryGB } func (m *MockHostProfile) GetAvailableMemoryGB() uint64 { return m.AvailableMemoryGB } func (m *MockHostProfile) GetTotalStorageGB() uint64 { return m.TotalStorageGB } +func (m *MockHostProfile) GetSSDStorageGB() uint64 { return m.SSDStorageGB } +func (m *MockHostProfile) GetHDDStorageGB() uint64 { return m.HDDStorageGB } func (m *MockHostProfile) String() string { return "MockHostProfile" } // IsNodeAlreadyRunning is a mock implementation for testing @@ -86,35 +111,45 @@ func TestIsNodeAlreadyRunning(t *testing.T) { func TestNodeSpecValidationWithRunningNode(t *testing.T) { tests := []struct { name string - createSpec func(HostProfile) Spec + nodeType string + profile string + hostProfile HostProfile nodeRunning bool expectValidation bool description string }{ { - name: "Local node validation when node is not running", - createSpec: NewLocalNodeSpec, + name: "Block node local validation when node is not running", + nodeType: core.NodeTypeBlock, + profile: core.ProfileLocal, + hostProfile: NewMockHostProfileWithNodeStatus("ubuntu", "20.04", 4, 4, 10, false), nodeRunning: false, expectValidation: true, description: "Should validate successfully when no node is running", }, { - name: "Local node validation when node is already running", - createSpec: NewLocalNodeSpec, + name: "Block node local validation when node is already running", + nodeType: core.NodeTypeBlock, + profile: core.ProfileLocal, + hostProfile: NewMockHostProfileWithNodeStatus("ubuntu", "20.04", 4, 4, 10, true), nodeRunning: true, - expectValidation: true, // Assuming validation doesn't fail just because node is running + expectValidation: true, description: "Should still validate hardware requirements even if node is running", }, { - name: "Block node validation when node is not running", - createSpec: NewBlockNodeSpec, + name: "Block node mainnet validation when node is not running", + nodeType: core.NodeTypeBlock, + profile: core.ProfileMainnet, + hostProfile: NewMockHostProfileWithNodeStatus("ubuntu", "20.04", 8, 22, 6000, false), // 8 cores, 16GB+ available, 5TB+ nodeRunning: false, expectValidation: true, description: "Should validate successfully when no node is running", }, { - name: "Consensus node validation when node is already running", - createSpec: NewConsensusNodeSpec, + name: "Consensus node mainnet validation when node is already running", + nodeType: core.NodeTypeConsensus, + profile: core.ProfileMainnet, + hostProfile: NewMockHostProfileWithNodeStatus("ubuntu", "20.04", 48, 322, 9000, true), // 48 cores, 256GB+ available, 8TB+ nodeRunning: true, expectValidation: true, description: "Should still validate hardware requirements even if node is running", @@ -123,14 +158,15 @@ func TestNodeSpecValidationWithRunningNode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create a mock with sufficient resources for all node types - mock := NewMockHostProfileWithNodeStatus("ubuntu", "20.04", 16, 32, 6000, tt.nodeRunning) - spec := tt.createSpec(mock) + spec, err := NewNodeSpec(tt.nodeType, tt.profile, tt.hostProfile) + if err != nil { + t.Fatalf("Failed to create spec: %v", err) + } // Test that IsNodeAlreadyRunning returns the expected value - if mock.IsNodeAlreadyRunning() != tt.nodeRunning { + if tt.hostProfile.IsNodeAlreadyRunning() != tt.nodeRunning { t.Errorf("Expected IsNodeAlreadyRunning() to return %v, got %v", - tt.nodeRunning, mock.IsNodeAlreadyRunning()) + tt.nodeRunning, tt.hostProfile.IsNodeAlreadyRunning()) } // Test that hardware validation still works regardless of node running status @@ -191,7 +227,8 @@ func TestMockHostProfile(t *testing.T) { func TestNodeSpecWithMockHostProfile(t *testing.T) { tests := []struct { name string - createSpec func(HostProfile) Spec + nodeType string + profile string actualHostProfile HostProfile expectedOS bool expectedCPU bool @@ -199,8 +236,9 @@ func TestNodeSpecWithMockHostProfile(t *testing.T) { expectedStorage bool }{ { - name: "Local node with sufficient resources", - createSpec: NewLocalNodeSpec, + name: "Block node local with sufficient resources", + nodeType: core.NodeTypeBlock, + profile: core.ProfileLocal, actualHostProfile: NewMockHostProfile("ubuntu", "20.04", 4, 4, 600), expectedOS: true, expectedCPU: true, @@ -208,8 +246,9 @@ func TestNodeSpecWithMockHostProfile(t *testing.T) { expectedStorage: true, }, { - name: "Local node with insufficient CPU", - createSpec: NewLocalNodeSpec, + name: "Block node local with insufficient CPU", + nodeType: core.NodeTypeBlock, + profile: core.ProfileLocal, actualHostProfile: NewMockHostProfile("ubuntu", "20.04", 0, 4, 600), expectedOS: true, expectedCPU: false, @@ -217,17 +256,19 @@ func TestNodeSpecWithMockHostProfile(t *testing.T) { expectedStorage: true, }, { - name: "Block node with sufficient resources", - createSpec: NewBlockNodeSpec, - actualHostProfile: NewMockHostProfile("ubuntu", "20.04", 8, 22, 6000), // Increased from 16 to 22 to account for system buffer + name: "Block node mainnet with sufficient resources", + nodeType: core.NodeTypeBlock, + profile: core.ProfileMainnet, + actualHostProfile: NewMockHostProfile("ubuntu", "20.04", 8, 22, 6000), expectedOS: true, expectedCPU: true, expectedMem: true, expectedStorage: true, }, { - name: "Block node with insufficient memory", - createSpec: NewBlockNodeSpec, + name: "Block node mainnet with insufficient memory", + nodeType: core.NodeTypeBlock, + profile: core.ProfileMainnet, actualHostProfile: NewMockHostProfile("ubuntu", "20.04", 8, 8, 6000), expectedOS: true, expectedCPU: true, @@ -235,26 +276,29 @@ func TestNodeSpecWithMockHostProfile(t *testing.T) { expectedStorage: true, }, { - name: "Consensus node with sufficient resources", - createSpec: NewConsensusNodeSpec, - actualHostProfile: NewMockHostProfile("ubuntu", "20.04", 16, 42, 1200), // Increased from 32 to 42 to account for system buffer + name: "Consensus node mainnet with sufficient resources", + nodeType: core.NodeTypeConsensus, + profile: core.ProfileMainnet, + actualHostProfile: NewMockHostProfile("ubuntu", "20.04", 48, 322, 9000), // 48 cores, 256GB+ available (322*0.8=257), 8TB+ expectedOS: true, expectedCPU: true, expectedMem: true, expectedStorage: true, }, { - name: "Consensus node with insufficient storage", - createSpec: NewConsensusNodeSpec, - actualHostProfile: NewMockHostProfile("ubuntu", "20.04", 16, 42, 500), // Increased from 32 to 42 to account for system buffer + name: "Consensus node mainnet with insufficient storage", + nodeType: core.NodeTypeConsensus, + profile: core.ProfileMainnet, + actualHostProfile: NewMockHostProfile("ubuntu", "20.04", 48, 322, 5000), // sufficient CPU/mem, insufficient storage (need 8TB) expectedOS: true, expectedCPU: true, expectedMem: true, expectedStorage: false, }, { - name: "Node with unsupported OS", - createSpec: NewLocalNodeSpec, + name: "Block node local with unsupported OS", + nodeType: core.NodeTypeBlock, + profile: core.ProfileLocal, actualHostProfile: NewMockHostProfile("windows", "10", 4, 4, 600), expectedOS: false, expectedCPU: true, @@ -265,7 +309,10 @@ func TestNodeSpecWithMockHostProfile(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - spec := tt.createSpec(tt.actualHostProfile) + spec, err := NewNodeSpec(tt.nodeType, tt.profile, tt.actualHostProfile) + if err != nil { + t.Fatalf("Failed to create spec: %v", err) + } osErr := spec.ValidateOS() if (osErr == nil) != tt.expectedOS { @@ -347,22 +394,137 @@ func TestOSValidation(t *testing.T) { } } -func TestIndividualNodeTypes(t *testing.T) { - // Test that each node type returns the correct node type string - mockHostProfile := NewMockHostProfile("ubuntu", "20.04", 16, 32, 1200) +func TestNodeTypeAndProfileCombinations(t *testing.T) { + // Test that each node type + profile returns the correct display name + mockHostProfile := NewMockHostProfile("ubuntu", "20.04", 48, 322, 9000) - localSpec := NewLocalNodeSpec(mockHostProfile) - if localSpec.GetNodeType() != "Local Node" { - t.Errorf("Expected Local Node type, got '%s'", localSpec.GetNodeType()) + tests := []struct { + nodeType string + profile string + expectedName string + }{ + {core.NodeTypeBlock, core.ProfileLocal, "Block Node (Local)"}, + {core.NodeTypeBlock, core.ProfileMainnet, "Block Node (Mainnet)"}, + {core.NodeTypeBlock, core.ProfilePreviewnet, "Block Node (Previewnet)"}, + {core.NodeTypeConsensus, core.ProfileLocal, "Consensus Node (Local)"}, + {core.NodeTypeConsensus, core.ProfileMainnet, "Consensus Node (Mainnet)"}, + {core.NodeTypeConsensus, core.ProfilePreviewnet, "Consensus Node (Previewnet)"}, } - blockSpec := NewBlockNodeSpec(mockHostProfile) - if blockSpec.GetNodeType() != "Block Node" { - t.Errorf("Expected Block Node type, got '%s'", blockSpec.GetNodeType()) + for _, tt := range tests { + t.Run(tt.nodeType+"_"+tt.profile, func(t *testing.T) { + spec, err := NewNodeSpec(tt.nodeType, tt.profile, mockHostProfile) + if err != nil { + t.Fatalf("Failed to create spec: %v", err) + } + if spec.GetNodeType() != tt.expectedName { + t.Errorf("Expected node type '%s', got '%s'", tt.expectedName, spec.GetNodeType()) + } + }) } +} - consensusSpec := NewConsensusNodeSpec(mockHostProfile) - if consensusSpec.GetNodeType() != "Consensus Node" { - t.Errorf("Expected Consensus Node type, got '%s'", consensusSpec.GetNodeType()) +func TestPreviewnetNodeSpec(t *testing.T) { + tests := []struct { + name string + actualHostProfile HostProfile + expectedOS bool + expectedCPU bool + expectedMem bool + expectedStorage bool + }{ + { + name: "Previewnet block node with sufficient resources", + actualHostProfile: NewMockHostProfileWithStorage("ubuntu", "20.04", 48, 322, 9000, 25000), // 9TB SSD, 25TB HDD + expectedOS: true, + expectedCPU: true, + expectedMem: true, + expectedStorage: true, + }, + { + name: "Previewnet block node with insufficient CPU", + actualHostProfile: NewMockHostProfileWithStorage("ubuntu", "20.04", 32, 322, 9000, 25000), + expectedOS: true, + expectedCPU: false, + expectedMem: true, + expectedStorage: true, + }, + { + name: "Previewnet block node with insufficient memory", + actualHostProfile: NewMockHostProfileWithStorage("ubuntu", "20.04", 48, 128, 9000, 25000), + expectedOS: true, + expectedCPU: true, + expectedMem: false, + expectedStorage: true, + }, + { + name: "Previewnet block node with insufficient SSD", + actualHostProfile: NewMockHostProfileWithStorage("ubuntu", "20.04", 48, 322, 5000, 25000), // Only 5TB SSD + expectedOS: true, + expectedCPU: true, + expectedMem: true, + expectedStorage: false, + }, + { + name: "Previewnet block node with insufficient HDD", + actualHostProfile: NewMockHostProfileWithStorage("ubuntu", "20.04", 48, 322, 9000, 10000), // Only 10TB HDD + expectedOS: true, + expectedCPU: true, + expectedMem: true, + expectedStorage: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec, err := NewNodeSpec(core.NodeTypeBlock, core.ProfilePreviewnet, tt.actualHostProfile) + if err != nil { + t.Fatalf("Failed to create spec: %v", err) + } + + osErr := spec.ValidateOS() + if (osErr == nil) != tt.expectedOS { + t.Errorf("OS validation expected %v, got error: %v", tt.expectedOS, osErr) + } + + cpuErr := spec.ValidateCPU() + if (cpuErr == nil) != tt.expectedCPU { + t.Errorf("CPU validation expected %v, got error: %v", tt.expectedCPU, cpuErr) + } + + memErr := spec.ValidateMemory() + if (memErr == nil) != tt.expectedMem { + t.Errorf("Memory validation expected %v, got error: %v", tt.expectedMem, memErr) + } + + storageErr := spec.ValidateStorage() + if (storageErr == nil) != tt.expectedStorage { + t.Errorf("Storage validation expected %v, got error: %v", tt.expectedStorage, storageErr) + } + }) + } +} + +func TestPreviewnetNodeRequirements(t *testing.T) { + mockHostProfile := NewMockHostProfileWithStorage("ubuntu", "20.04", 48, 322, 9000, 25000) + spec, err := NewNodeSpec(core.NodeTypeBlock, core.ProfilePreviewnet, mockHostProfile) + if err != nil { + t.Fatalf("Failed to create spec: %v", err) + } + + requirements := spec.GetBaselineRequirements() + + // Verify previewnet requirements: 48 cores, 256GB RAM, 8TB SSD, 24TB HDD + if requirements.MinCpuCores != 48 { + t.Errorf("Expected 48 CPU cores, got %d", requirements.MinCpuCores) + } + if requirements.MinMemoryGB != 256 { + t.Errorf("Expected 256 GB memory, got %d", requirements.MinMemoryGB) + } + if requirements.MinSSDStorageGB != 8000 { + t.Errorf("Expected 8000 GB SSD storage, got %d", requirements.MinSSDStorageGB) + } + if requirements.MinHDDStorageGB != 24000 { + t.Errorf("Expected 24000 GB HDD storage, got %d", requirements.MinHDDStorageGB) } } diff --git a/pkg/hardware/host_profile.go b/pkg/hardware/host_profile.go index 8427da5d..8ae558b0 100644 --- a/pkg/hardware/host_profile.go +++ b/pkg/hardware/host_profile.go @@ -36,6 +36,8 @@ type HostProfile interface { // Storage information (in GB) GetTotalStorageGB() uint64 + GetSSDStorageGB() uint64 // NVMe/SSD storage + GetHDDStorageGB() uint64 // Traditional spinning disk storage // Application status IsNodeAlreadyRunning() bool @@ -43,9 +45,18 @@ type HostProfile interface { String() string } +// cachedBlockInfo holds pre-computed storage values from a single ghw.Block() call +type cachedBlockInfo struct { + totalGB uint64 + ssdGB uint64 + hddGB uint64 +} + // DefaultHostProfile implements HostProfile using both sysinfo and ghw libraries type DefaultHostProfile struct { - sysInfo sysinfo.SysInfo + sysInfo sysinfo.SysInfo + blockInfo *cachedBlockInfo + blockOnce sync.Once } // GetHostProfile creates a new DefaultHostProfile by gathering system information @@ -61,6 +72,35 @@ func GetHostProfile() HostProfile { } } +// getBlockInfo returns cached block storage info, fetching it once if needed +func (d *DefaultHostProfile) getBlockInfo() *cachedBlockInfo { + d.blockOnce.Do(func() { + block, err := ghw.Block() + if err != nil { + log.Printf("Error getting block info from ghw: %v", err) + d.blockInfo = &cachedBlockInfo{} + return + } + + var ssdBytes, hddBytes uint64 + for _, disk := range block.Disks { + switch disk.DriveType { + case ghw.DriveTypeSSD: + ssdBytes += disk.SizeBytes + case ghw.DriveTypeHDD: + hddBytes += disk.SizeBytes + } + } + + d.blockInfo = &cachedBlockInfo{ + totalGB: block.TotalPhysicalBytes / (1024 * 1024 * 1024), + ssdGB: ssdBytes / (1024 * 1024 * 1024), + hddGB: hddBytes / (1024 * 1024 * 1024), + } + }) + return d.blockInfo +} + // GetOSVendor returns the OS vendor/distribution name func (d *DefaultHostProfile) GetOSVendor() string { return d.sysInfo.OS.Vendor @@ -73,7 +113,6 @@ func (d *DefaultHostProfile) GetOSVersion() string { // GetCPUCores returns the number of CPU cores func (d *DefaultHostProfile) GetCPUCores() uint { - // Use ghw for CPU information cpu, err := ghw.CPU() if err != nil { log.Printf("Error getting CPU info from ghw: %v", err) @@ -94,34 +133,33 @@ func (d *DefaultHostProfile) GetTotalMemoryGB() uint64 { // GetTotalStorageGB returns total storage space in GB func (d *DefaultHostProfile) GetTotalStorageGB() uint64 { - // Use ghw for storage information - block, err := ghw.Block() - if err != nil { - log.Printf("Error getting block info from ghw: %v", err) - return 0 - } - return uint64(block.TotalPhysicalBytes / (1024 * 1024 * 1024)) + return d.getBlockInfo().totalGB +} + +// GetSSDStorageGB returns total SSD/NVMe storage in GB +func (d *DefaultHostProfile) GetSSDStorageGB() uint64 { + return d.getBlockInfo().ssdGB +} + +// GetHDDStorageGB returns total HDD (spinning disk) storage in GB +func (d *DefaultHostProfile) GetHDDStorageGB() uint64 { + return d.getBlockInfo().hddGB } // GetAvailableMemoryGB returns available system memory in GB func (d *DefaultHostProfile) GetAvailableMemoryGB() uint64 { - // Use ghw for memory information memory, err := ghw.Memory() if err != nil { log.Printf("Error getting memory info from ghw: %v", err) return 0 } - - // Return usable memory as available memory return uint64(memory.TotalUsableBytes / (1024 * 1024 * 1024)) } // IsNodeAlreadyRunning checks if the node is already running by looking for a lock file func (d *DefaultHostProfile) IsNodeAlreadyRunning() bool { - // Hardcoded lock file path - adjust this to match your application's lock file location lockFilePath := "/var/run/solo-node.lock" - // Check if the lock file exists if _, err := os.Stat(lockFilePath); os.IsNotExist(err) { return false } diff --git a/pkg/hardware/local_node.go b/pkg/hardware/local_node.go deleted file mode 100644 index df49689e..00000000 --- a/pkg/hardware/local_node.go +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package hardware - -// localNode represents a local node with its specific requirements and validation logic -type localNode struct { - baseNode -} - -// Ensure localNode implements Spec -var _ Spec = (*localNode)(nil) - -// NewLocalNodeSpec creates a new local node specification checker with SystemInfo interface -func NewLocalNodeSpec(hostProfile HostProfile) Spec { - return &localNode{ - baseNode: baseNode{ - nodeType: "Local Node", - actualHostProfile: hostProfile, - // Note: The minimum CPU cores requirement is set to 3 (instead of 1) to ensure - // that observability components (e.g., Alloy, Node Exporter, etc.) can run - // alongside the local cluster workloads on the same node. - minimalRequirements: BaselineRequirements{ - MinCpuCores: 3, - MinMemoryGB: 1, - MinStorageGB: 1, - MinSupportedOS: []string{"Ubuntu 18", "Debian 10"}, - }, - }, - } -} diff --git a/pkg/hardware/node_spec.go b/pkg/hardware/node_spec.go new file mode 100644 index 00000000..4146b7fb --- /dev/null +++ b/pkg/hardware/node_spec.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hardware + +import "fmt" + +// nodeSpec implements Spec by combining requirements from the registry +// with the actual host profile for validation. +type nodeSpec struct { + baseNode + rawNodeType string + profile string +} + +// Ensure nodeSpec implements Spec +var _ Spec = (*nodeSpec)(nil) + +// NewNodeSpec creates a node specification for the given node type, profile, and host profile. +// Requirements are looked up from the registry based on (nodeType, profile). +func NewNodeSpec(nodeType, profile string, hostProfile HostProfile) (Spec, error) { + requirements, found := GetRequirements(nodeType, profile) + if !found { + return nil, fmt.Errorf("no requirements defined for node type %q with profile %q", nodeType, profile) + } + + return &nodeSpec{ + baseNode: baseNode{ + nodeType: formatDisplayName(nodeType, profile), + actualHostProfile: hostProfile, + minimalRequirements: requirements, + }, + rawNodeType: nodeType, + profile: profile, + }, nil +} + +// formatDisplayName creates a human-readable display name +func formatDisplayName(nodeType, profile string) string { + return fmt.Sprintf("%s Node (%s)", capitalize(nodeType), capitalize(profile)) +} + +// capitalize capitalizes the first letter of a string +func capitalize(s string) string { + if len(s) == 0 { + return s + } + // Simple ASCII uppercase for first char + if s[0] >= 'a' && s[0] <= 'z' { + return string(s[0]-('a'-'A')) + s[1:] + } + return s +} + +// GetProfile returns the deployment profile +func (n *nodeSpec) GetProfile() string { + return n.profile +} + +// GetRawNodeType returns the raw node type (e.g., "block", "consensus") +func (n *nodeSpec) GetRawNodeType() string { + return n.rawNodeType +} diff --git a/pkg/hardware/requirements.go b/pkg/hardware/requirements.go new file mode 100644 index 00000000..7cee22dc --- /dev/null +++ b/pkg/hardware/requirements.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hardware + +import "github.com/hashgraph/solo-weaver/internal/core" + +// Supported OS constants +const ( + OSUbuntu18 = "Ubuntu 18" + OSDebian10 = "Debian 10" +) + +// Common OS requirement sets +var ( + supportedOS = []string{OSUbuntu18, OSDebian10} +) + +// requirementsRegistry holds the hardware requirements for each (nodeType, profile) combination. +// This design separates the two orthogonal concerns: +// - Node Type: what kind of node (block, consensus) +// - Profile/Environment: where it runs (local, testnet, mainnet, previewnet, perfnet) +// +// The requirements are looked up as: registry[nodeType][profile] -> BaselineRequirements +var requirementsRegistry = map[string]map[string]BaselineRequirements{ + // Block Node requirements per environment + core.NodeTypeBlock: { + core.ProfileLocal: { + MinCpuCores: 3, + MinMemoryGB: 1, + MinStorageGB: 1, + MinSupportedOS: supportedOS, + }, + core.ProfilePerfnet: { + MinCpuCores: 8, + MinMemoryGB: 16, + MinStorageGB: 5000, + MinSupportedOS: supportedOS, + }, + core.ProfileTestnet: { + MinCpuCores: 8, + MinMemoryGB: 16, + MinStorageGB: 5000, + MinSupportedOS: supportedOS, + }, + core.ProfilePreviewnet: { + MinCpuCores: 48, + MinMemoryGB: 256, + MinSSDStorageGB: 8000, // 8TB NVMe/SSD + MinHDDStorageGB: 24000, // 24TB HDD + MinSupportedOS: supportedOS, + }, + core.ProfileMainnet: { + MinCpuCores: 8, + MinMemoryGB: 16, + MinStorageGB: 5000, + MinSupportedOS: supportedOS, + }, + }, + + // Consensus Node requirements per environment + core.NodeTypeConsensus: { + core.ProfileLocal: { + MinCpuCores: 3, + MinMemoryGB: 1, + MinStorageGB: 1, + MinSupportedOS: supportedOS, + }, + core.ProfilePerfnet: { + MinCpuCores: 16, + MinMemoryGB: 32, + MinStorageGB: 1000, + MinSupportedOS: supportedOS, + }, + core.ProfileTestnet: { + MinCpuCores: 16, + MinMemoryGB: 32, + MinStorageGB: 1000, + MinSupportedOS: supportedOS, + }, + core.ProfilePreviewnet: { + MinCpuCores: 48, + MinMemoryGB: 256, + MinStorageGB: 8000, + MinSupportedOS: supportedOS, + }, + core.ProfileMainnet: { + MinCpuCores: 48, + MinMemoryGB: 256, + MinStorageGB: 8000, + MinSupportedOS: supportedOS, + }, + }, +} + +// GetRequirements returns the hardware requirements for a given node type and profile. +// Returns the requirements and true if found, or empty requirements and false if not found. +func GetRequirements(nodeType, profile string) (BaselineRequirements, bool) { + if nodeReqs, ok := requirementsRegistry[nodeType]; ok { + if reqs, ok := nodeReqs[profile]; ok { + return reqs, true + } + } + return BaselineRequirements{}, false +} diff --git a/pkg/hardware/requirements_test.go b/pkg/hardware/requirements_test.go new file mode 100644 index 00000000..6d79081b --- /dev/null +++ b/pkg/hardware/requirements_test.go @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hardware + +import ( + "testing" + + "github.com/hashgraph/solo-weaver/internal/core" +) + +func TestRequirementsRegistry(t *testing.T) { + // Test that all expected combinations exist in the registry + nodeTypes := []string{core.NodeTypeBlock, core.NodeTypeConsensus} + profiles := []string{core.ProfileLocal, core.ProfilePerfnet, core.ProfileTestnet, core.ProfilePreviewnet, core.ProfileMainnet} + + for _, nodeType := range nodeTypes { + for _, profile := range profiles { + t.Run(nodeType+"_"+profile, func(t *testing.T) { + reqs, found := GetRequirements(nodeType, profile) + if !found { + t.Errorf("Expected requirements for node type %q and profile %q to exist", nodeType, profile) + } + // Basic sanity checks + if reqs.MinCpuCores <= 0 { + t.Errorf("Expected MinCpuCores > 0 for %s/%s, got %d", nodeType, profile, reqs.MinCpuCores) + } + if reqs.MinMemoryGB <= 0 { + t.Errorf("Expected MinMemoryGB > 0 for %s/%s, got %d", nodeType, profile, reqs.MinMemoryGB) + } + // Storage can be either total or SSD+HDD + hasStorage := reqs.MinStorageGB > 0 || reqs.MinSSDStorageGB > 0 || reqs.MinHDDStorageGB > 0 + if !hasStorage { + t.Errorf("Expected some storage requirement for %s/%s", nodeType, profile) + } + if len(reqs.MinSupportedOS) == 0 { + t.Errorf("Expected at least one supported OS for %s/%s", nodeType, profile) + } + }) + } + } +} + +func TestRequirementsNotFoundForInvalidInput(t *testing.T) { + tests := []struct { + name string + nodeType string + profile string + }{ + {"Invalid node type", "invalid", core.ProfileMainnet}, + {"Invalid profile", core.NodeTypeBlock, "invalid"}, + {"Both invalid", "invalid", "invalid"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, found := GetRequirements(tt.nodeType, tt.profile) + if found { + t.Errorf("Expected requirements to NOT be found for node type %q and profile %q", tt.nodeType, tt.profile) + } + }) + } +} + +func TestPreviewnetRequirementsAreHigher(t *testing.T) { + // Previewnet should have higher requirements than other profiles + for _, nodeType := range []string{core.NodeTypeBlock, core.NodeTypeConsensus} { + previewnetReqs, _ := GetRequirements(nodeType, core.ProfilePreviewnet) + testnetReqs, _ := GetRequirements(nodeType, core.ProfileTestnet) + + if previewnetReqs.MinCpuCores <= testnetReqs.MinCpuCores { + t.Errorf("Expected previewnet CPU cores (%d) > testnet CPU cores (%d) for %s", + previewnetReqs.MinCpuCores, testnetReqs.MinCpuCores, nodeType) + } + if previewnetReqs.MinMemoryGB <= testnetReqs.MinMemoryGB { + t.Errorf("Expected previewnet memory (%d) > testnet memory (%d) for %s", + previewnetReqs.MinMemoryGB, testnetReqs.MinMemoryGB, nodeType) + } + + // For block nodes, previewnet uses SSD+HDD; for others, compare total storage + if nodeType == core.NodeTypeBlock { + // Block node previewnet uses SSD+HDD split + totalPreviewnet := previewnetReqs.MinSSDStorageGB + previewnetReqs.MinHDDStorageGB + if totalPreviewnet <= testnetReqs.MinStorageGB { + t.Errorf("Expected previewnet total storage (%d) > testnet storage (%d) for %s", + totalPreviewnet, testnetReqs.MinStorageGB, nodeType) + } + } else { + if previewnetReqs.MinStorageGB <= testnetReqs.MinStorageGB { + t.Errorf("Expected previewnet storage (%d) > testnet storage (%d) for %s", + previewnetReqs.MinStorageGB, testnetReqs.MinStorageGB, nodeType) + } + } + } +} + +func TestLocalProfileHasMinimalRequirements(t *testing.T) { + // Local profile should have minimal requirements for development + for _, nodeType := range []string{core.NodeTypeBlock, core.NodeTypeConsensus} { + localReqs, _ := GetRequirements(nodeType, core.ProfileLocal) + mainnetReqs, _ := GetRequirements(nodeType, core.ProfileMainnet) + + if localReqs.MinCpuCores >= mainnetReqs.MinCpuCores { + t.Errorf("Expected local CPU cores (%d) < mainnet CPU cores (%d) for %s", + localReqs.MinCpuCores, mainnetReqs.MinCpuCores, nodeType) + } + if localReqs.MinMemoryGB >= mainnetReqs.MinMemoryGB { + t.Errorf("Expected local memory (%d) < mainnet memory (%d) for %s", + localReqs.MinMemoryGB, mainnetReqs.MinMemoryGB, nodeType) + } + } +} + +func TestNewNodeSpecWithRegistry(t *testing.T) { + mockHost := NewMockHostProfile("ubuntu", "20.04", 48, 322, 9000) + + tests := []struct { + name string + nodeType string + profile string + expectError bool + }{ + {"Block node mainnet", core.NodeTypeBlock, core.ProfileMainnet, false}, + {"Block node previewnet", core.NodeTypeBlock, core.ProfilePreviewnet, false}, + {"Consensus node local", core.NodeTypeConsensus, core.ProfileLocal, false}, + {"Invalid node type", "invalid", core.ProfileMainnet, true}, + {"Invalid profile", core.NodeTypeBlock, "invalid", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec, err := NewNodeSpec(tt.nodeType, tt.profile, mockHost) + if tt.expectError { + if err == nil { + t.Errorf("Expected error for node type %q and profile %q", tt.nodeType, tt.profile) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if spec == nil { + t.Error("Expected non-nil spec") + } + } + }) + } +} + +func TestCreateNodeSpecValidation(t *testing.T) { + mockHost := NewMockHostProfile("ubuntu", "20.04", 48, 322, 9000) + + tests := []struct { + name string + nodeType string + profile string + expectError bool + }{ + {"Valid block/mainnet", core.NodeTypeBlock, core.ProfileMainnet, false}, + {"Valid consensus/previewnet", core.NodeTypeConsensus, core.ProfilePreviewnet, false}, + {"Invalid node type", "invalid", core.ProfileMainnet, true}, + {"Invalid profile", core.NodeTypeBlock, "invalid", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec, err := CreateNodeSpec(tt.nodeType, tt.profile, mockHost) + if tt.expectError { + if err == nil { + t.Errorf("Expected error for node type %q and profile %q", tt.nodeType, tt.profile) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if spec == nil { + t.Error("Expected non-nil spec") + } + } + }) + } +} + +func TestNodeSpecValidationWithDifferentProfiles(t *testing.T) { + // Test that the same node type with different profiles has different requirements + tests := []struct { + name string + nodeType string + profile string + actualHostProfile HostProfile + expectCPUPass bool + expectMemPass bool + expectStoragePass bool + }{ + { + name: "Block node local - minimal resources should pass", + nodeType: core.NodeTypeBlock, + profile: core.ProfileLocal, + actualHostProfile: NewMockHostProfile("ubuntu", "20.04", 4, 4, 10), + expectCPUPass: true, + expectMemPass: true, + expectStoragePass: true, + }, + { + name: "Block node mainnet - minimal resources should fail", + nodeType: core.NodeTypeBlock, + profile: core.ProfileMainnet, + actualHostProfile: NewMockHostProfile("ubuntu", "20.04", 4, 4, 10), + expectCPUPass: false, + expectMemPass: false, + expectStoragePass: false, + }, + { + name: "Block node previewnet - high resources should pass", + nodeType: core.NodeTypeBlock, + profile: core.ProfilePreviewnet, + actualHostProfile: NewMockHostProfileWithStorage("ubuntu", "20.04", 48, 322, 9000, 25000), // 9TB SSD, 25TB HDD + expectCPUPass: true, + expectMemPass: true, + expectStoragePass: true, + }, + { + name: "Block node previewnet - medium resources should fail", + nodeType: core.NodeTypeBlock, + profile: core.ProfilePreviewnet, + actualHostProfile: NewMockHostProfileWithStorage("ubuntu", "20.04", 16, 64, 5000, 10000), // insufficient + expectCPUPass: false, + expectMemPass: false, + expectStoragePass: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec, err := CreateNodeSpec(tt.nodeType, tt.profile, tt.actualHostProfile) + if err != nil { + t.Fatalf("Failed to create spec: %v", err) + } + + cpuErr := spec.ValidateCPU() + if (cpuErr == nil) != tt.expectCPUPass { + t.Errorf("CPU validation: expected pass=%v, got error=%v", tt.expectCPUPass, cpuErr) + } + + memErr := spec.ValidateMemory() + if (memErr == nil) != tt.expectMemPass { + t.Errorf("Memory validation: expected pass=%v, got error=%v", tt.expectMemPass, memErr) + } + + storageErr := spec.ValidateStorage() + if (storageErr == nil) != tt.expectStoragePass { + t.Errorf("Storage validation: expected pass=%v, got error=%v", tt.expectStoragePass, storageErr) + } + }) + } +} diff --git a/pkg/hardware/spec.go b/pkg/hardware/spec.go index 31d5e6b8..8e16b6fa 100644 --- a/pkg/hardware/spec.go +++ b/pkg/hardware/spec.go @@ -17,13 +17,19 @@ type Spec interface { } type BaselineRequirements struct { - MinCpuCores int - MinMemoryGB int - MinStorageGB int - MinSupportedOS []string + MinCpuCores int + MinMemoryGB int + MinStorageGB int // Total storage (used when SSD/HDD split not required) + MinSSDStorageGB int // Minimum SSD/NVMe storage (0 means not required) + MinHDDStorageGB int // Minimum HDD storage (0 means not required) + MinSupportedOS []string } func (r BaselineRequirements) String() string { - return fmt.Sprintf("OS: %v, CPU: %d cores, Memory: %d GB, Storage: %d GB, ", + if r.MinSSDStorageGB > 0 || r.MinHDDStorageGB > 0 { + return fmt.Sprintf("OS: %v, CPU: %d cores, Memory: %d GB, SSD: %d GB, HDD: %d GB", + r.MinSupportedOS, r.MinCpuCores, r.MinMemoryGB, r.MinSSDStorageGB, r.MinHDDStorageGB) + } + return fmt.Sprintf("OS: %v, CPU: %d cores, Memory: %d GB, Storage: %d GB", r.MinSupportedOS, r.MinCpuCores, r.MinMemoryGB, r.MinStorageGB) }