edge-connect-client/internal/apply/v2/planner_test.go
Richard Robert Reitz 732e10fc16
Some checks failed
test / test (push) Failing after 48s
feat(apply): add v1 API support to apply command
Refactor apply command to support both v1 and v2 APIs:
- Split internal/apply into v1 and v2 subdirectories
- v1: Uses sdk/edgeconnect (from revision/v1 branch)
- v2: Uses sdk/edgeconnect/v2
- Update cmd/apply.go to route to appropriate version based on api_version config
- Both versions now fully functional with their respective API endpoints

Changes:
- Created internal/apply/v1/ with v1 SDK implementation
- Created internal/apply/v2/ with v2 SDK implementation
- Updated cmd/apply.go with runApplyV1() and runApplyV2() functions
- Removed validation error that rejected v1
- Apply command now respects --api-version flag and config setting

Testing:
- V1 with edge.platform:  Generates deployment plan correctly
- V2 with orca.platform:  Works as before

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 13:57:57 +02:00

663 lines
19 KiB
Go

// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios
// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios
package v2
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// MockEdgeConnectClient is a mock implementation of the EdgeConnect client
type MockEdgeConnectClient struct {
mock.Mock
}
func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) {
args := m.Called(ctx, appKey, region)
if args.Get(0) == nil {
return v2.App{}, args.Error(1)
}
return args.Get(0).(v2.App), args.Error(1)
}
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) (v2.AppInstance, error) {
args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil {
return v2.AppInstance{}, args.Error(1)
}
return args.Get(0).(v2.AppInstance), args.Error(1)
}
func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *v2.NewAppInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
args := m.Called(ctx, appKey, region)
return args.Error(0)
}
func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
args := m.Called(ctx, instanceKey, region)
return args.Error(0)
}
func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey v2.AppKey, region string) ([]v2.App, error) {
args := m.Called(ctx, appKey, region)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]v2.App), args.Error(1)
}
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) {
args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]v2.AppInstance), args.Error(1)
}
func TestNewPlanner(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
assert.NotNil(t, planner)
assert.IsType(t, &EdgeConnectPlanner{}, planner)
}
func TestDefaultPlanOptions(t *testing.T) {
opts := DefaultPlanOptions()
assert.False(t, opts.DryRun)
assert.False(t, opts.Force)
assert.False(t, opts.SkipStateCheck)
assert.True(t, opts.ParallelQueries)
assert.Equal(t, 30*time.Second, opts.Timeout)
}
func createTestConfig(t *testing.T) *config.EdgeConnectConfig {
// Create temporary manifest file
tempDir := t.TempDir()
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
require.NoError(t, err)
return &config.EdgeConnectConfig{
Kind: "edgeconnect-deployment",
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "testorg",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{
ManifestFile: manifestFile,
},
InfraTemplate: []config.InfraTemplate{
{
Region: "US",
CloudletOrg: "TestCloudletOrg",
CloudletName: "TestCloudlet",
FlavorName: "small",
},
},
Network: &config.NetworkConfig{
OutboundConnections: []config.OutboundConnection{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
},
},
}
}
func TestPlanNewDeployment(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Mock API calls to return "not found" errors
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
require.NoError(t, result.Error)
plan := result.Plan
assert.Equal(t, "test-app", plan.ConfigName)
assert.Equal(t, ActionCreate, plan.AppAction.Type)
assert.Equal(t, "Application does not exist", plan.AppAction.Reason)
require.Len(t, plan.InstanceActions, 1)
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
assert.Equal(t, "Instance does not exist", plan.InstanceActions[0].Reason)
assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance
assert.False(t, plan.IsEmpty())
mockClient.AssertExpectations(t)
}
func TestPlanExistingDeploymentNoChanges(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Note: We would calculate expected manifest hash here when API supports it
// Mock existing app with same manifest hash and outbound connections
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
existingApp := &v2.App{
Key: v2.AppKey{
Organization: "testorg",
Name: "test-app",
Version: "1.0.0",
},
Deployment: "kubernetes",
DeploymentManifest: manifestContent,
RequiredOutboundConnections: []v2.SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
// Note: Manifest hash tracking would be implemented when API supports annotations
}
// Mock existing instance
existingInstance := &v2.AppInstance{
Key: v2.AppInstanceKey{
Organization: "testorg",
Name: "test-app-1.0.0-instance",
CloudletKey: v2.CloudletKey{
Organization: "TestCloudletOrg",
Name: "TestCloudlet",
},
},
AppKey: v2.AppKey{
Organization: "testorg",
Name: "test-app",
Version: "1.0.0",
},
Flavor: v2.Flavor{
Name: "small",
},
State: "Ready",
PowerState: "PowerOn",
}
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(*existingApp, nil)
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(*existingInstance, nil)
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.Equal(t, ActionNone, plan.AppAction.Type)
assert.Len(t, plan.InstanceActions, 1)
assert.Equal(t, ActionNone, plan.InstanceActions[0].Type)
assert.Equal(t, 0, plan.TotalActions)
assert.True(t, plan.IsEmpty())
assert.Contains(t, plan.Summary, "No changes required")
mockClient.AssertExpectations(t)
}
func TestPlanWithOptions(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
opts := PlanOptions{
DryRun: true,
SkipStateCheck: true,
Timeout: 10 * time.Second,
}
ctx := context.Background()
result, err := planner.PlanWithOptions(ctx, testConfig, opts)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.True(t, plan.DryRun)
assert.Equal(t, ActionCreate, plan.AppAction.Type)
assert.Contains(t, plan.AppAction.Reason, "state check skipped")
// No API calls should be made when SkipStateCheck is true
mockClient.AssertNotCalled(t, "ShowApp")
mockClient.AssertNotCalled(t, "ShowAppInstance")
}
func TestPlanMultipleInfrastructures(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Add a second infrastructure target
testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{
Region: "EU",
CloudletOrg: "EUCloudletOrg",
CloudletName: "EUCloudlet",
FlavorName: "medium",
})
// Mock API calls to return "not found" errors
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "EU").
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.Equal(t, ActionCreate, plan.AppAction.Type)
// Should have 2 instance actions, one for each infrastructure
require.Len(t, plan.InstanceActions, 2)
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
assert.Equal(t, ActionCreate, plan.InstanceActions[1].Type)
assert.Equal(t, 3, plan.TotalActions) // 1 app + 2 instances
// Test cloudlet and region aggregation
cloudlets := plan.GetTargetCloudlets()
regions := plan.GetTargetRegions()
assert.Len(t, cloudlets, 2)
assert.Len(t, regions, 2)
mockClient.AssertExpectations(t)
}
func TestCalculateManifestHash(t *testing.T) {
planner := &EdgeConnectPlanner{}
tempDir := t.TempDir()
// Create test file
testFile := filepath.Join(tempDir, "test.yaml")
content := "test content for hashing"
err := os.WriteFile(testFile, []byte(content), 0644)
require.NoError(t, err)
hash1, err := planner.calculateManifestHash(testFile)
require.NoError(t, err)
assert.NotEmpty(t, hash1)
assert.Len(t, hash1, 64) // SHA256 hex string length
// Same content should produce same hash
hash2, err := planner.calculateManifestHash(testFile)
require.NoError(t, err)
assert.Equal(t, hash1, hash2)
// Different content should produce different hash
err = os.WriteFile(testFile, []byte("different content"), 0644)
require.NoError(t, err)
hash3, err := planner.calculateManifestHash(testFile)
require.NoError(t, err)
assert.NotEqual(t, hash1, hash3)
// Empty file path should return empty hash
hash4, err := planner.calculateManifestHash("")
require.NoError(t, err)
assert.Empty(t, hash4)
// Non-existent file should return error
_, err = planner.calculateManifestHash("/non/existent/file")
assert.Error(t, err)
}
func TestCompareAppStates(t *testing.T) {
planner := &EdgeConnectPlanner{}
current := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
ManifestHash: "old-hash",
}
desired := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
ManifestHash: "new-hash",
}
changes, manifestChanged := planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.True(t, manifestChanged)
assert.Contains(t, changes[0], "Manifest hash changed")
// Test no changes
desired.ManifestHash = "old-hash"
changes, manifestChanged = planner.compareAppStates(current, desired)
assert.Empty(t, changes)
assert.False(t, manifestChanged)
// Test app type change
desired.AppType = AppTypeDocker
changes, manifestChanged = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.False(t, manifestChanged)
assert.Contains(t, changes[0], "App type changed")
}
func TestCompareAppStatesOutboundConnections(t *testing.T) {
planner := &EdgeConnectPlanner{}
// Test with no outbound connections
current := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
OutboundConnections: nil,
}
desired := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
OutboundConnections: nil,
}
changes, _ := planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when both have no outbound connections")
// Test adding outbound connections
desired.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
// Test identical outbound connections
current.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when outbound connections are identical")
// Test different outbound connections (different port)
desired.OutboundConnections[0].PortRangeMin = 443
desired.OutboundConnections[0].PortRangeMax = 443
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
// Test same connections but different order (should be considered equal)
current.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 443,
PortRangeMax: 443,
RemoteCIDR: "0.0.0.0/0",
},
}
desired.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 443,
PortRangeMax: 443,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order")
// Test removing outbound connections
desired.OutboundConnections = nil
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
}
func TestCompareInstanceStates(t *testing.T) {
planner := &EdgeConnectPlanner{}
current := &InstanceState{
Name: "test-instance",
FlavorName: "small",
CloudletName: "oldcloudlet",
CloudletOrg: "oldorg",
}
desired := &InstanceState{
Name: "test-instance",
FlavorName: "medium",
CloudletName: "newcloudlet",
CloudletOrg: "neworg",
}
changes := planner.compareInstanceStates(current, desired)
assert.Len(t, changes, 3)
assert.Contains(t, changes[0], "Flavor changed")
assert.Contains(t, changes[1], "Cloudlet changed")
assert.Contains(t, changes[2], "Cloudlet org changed")
// Test no changes
desired.FlavorName = "small"
desired.CloudletName = "oldcloudlet"
desired.CloudletOrg = "oldorg"
changes = planner.compareInstanceStates(current, desired)
assert.Empty(t, changes)
}
func TestDeploymentPlanMethods(t *testing.T) {
plan := &DeploymentPlan{
ConfigName: "test-plan",
AppAction: AppAction{
Type: ActionCreate,
Desired: &AppState{Name: "test-app"},
},
InstanceActions: []InstanceAction{
{
Type: ActionCreate,
Target: config.InfraTemplate{
CloudletOrg: "org1",
CloudletName: "cloudlet1",
Region: "US",
},
InstanceName: "instance1",
Desired: &InstanceState{Name: "instance1"},
},
{
Type: ActionUpdate,
Target: config.InfraTemplate{
CloudletOrg: "org2",
CloudletName: "cloudlet2",
Region: "EU",
},
InstanceName: "instance2",
Desired: &InstanceState{Name: "instance2"},
},
},
}
// Test IsEmpty
assert.False(t, plan.IsEmpty())
// Test GetTargetCloudlets
cloudlets := plan.GetTargetCloudlets()
assert.Len(t, cloudlets, 2)
assert.Contains(t, cloudlets, "org1:cloudlet1")
assert.Contains(t, cloudlets, "org2:cloudlet2")
// Test GetTargetRegions
regions := plan.GetTargetRegions()
assert.Len(t, regions, 2)
assert.Contains(t, regions, "US")
assert.Contains(t, regions, "EU")
// Test GenerateSummary
summary := plan.GenerateSummary()
assert.Contains(t, summary, "test-plan")
assert.Contains(t, summary, "CREATE application")
assert.Contains(t, summary, "CREATE 1 instance")
assert.Contains(t, summary, "UPDATE 1 instance")
// Test Validate
err := plan.Validate()
assert.NoError(t, err)
// Test validation failure
plan.AppAction.Desired = nil
err = plan.Validate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "must have desired state")
}
func TestEstimateDeploymentDuration(t *testing.T) {
planner := &EdgeConnectPlanner{}
plan := &DeploymentPlan{
AppAction: AppAction{Type: ActionCreate},
InstanceActions: []InstanceAction{
{Type: ActionCreate},
{Type: ActionUpdate},
},
}
duration := planner.estimateDeploymentDuration(plan)
assert.Greater(t, duration, time.Duration(0))
assert.Less(t, duration, 10*time.Minute) // Reasonable upper bound
// Test with no actions
emptyPlan := &DeploymentPlan{
AppAction: AppAction{Type: ActionNone},
InstanceActions: []InstanceAction{},
}
emptyDuration := planner.estimateDeploymentDuration(emptyPlan)
assert.Greater(t, emptyDuration, time.Duration(0))
assert.Less(t, emptyDuration, duration) // Should be less than plan with actions
}
func TestIsResourceNotFoundError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"nil error", nil, false},
{"not found error", &v2.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true},
{"does not exist error", &v2.APIError{Messages: []string{"App does not exist"}}, true},
{"404 in message", &v2.APIError{Messages: []string{"HTTP 404 error"}}, true},
{"other error", &v2.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isResourceNotFoundError(tt.err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestPlanErrorHandling(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Mock API call to return a non-404 error
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil, &v2.APIError{StatusCode: 500, Messages: []string{"Server error"}})
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
assert.Error(t, err)
assert.NotNil(t, result)
assert.NotNil(t, result.Error)
assert.Contains(t, err.Error(), "failed to query current app state")
mockClient.AssertExpectations(t)
}