From 42ae3f61d97a187638ab9e94a3aa13c85ccd46a3 Mon Sep 17 00:00:00 2001 From: Waldemar Date: Mon, 29 Sep 2025 17:35:34 +0200 Subject: [PATCH] chore(cli): Moved cli related packages out of sdk. Deleted duplicate files. --- cmd/apply.go | 4 +- {sdk => internal}/apply/manager.go | 2 +- {sdk => internal}/apply/manager_test.go | 6 +- {sdk => internal}/apply/planner.go | 10 +- .../apply/planner_test.go | 6 +- {sdk => internal}/apply/types.go | 4 +- {sdk => internal}/config/example_test.go | 0 {sdk => internal}/config/parser.go | 0 {sdk => internal}/config/parser_test.go | 0 {sdk => internal}/config/types.go | 0 sdk/apply/planner_test.go | 553 ------------ sdk/internal/apply/manager.go | 542 ------------ sdk/internal/apply/manager_test.go | 594 ------------- sdk/internal/apply/planner.go | 471 ----------- sdk/internal/apply/types.go | 428 ---------- sdk/internal/config/example_test.go | 130 --- sdk/internal/config/parser.go | 248 ------ sdk/internal/config/parser_test.go | 789 ------------------ sdk/internal/config/types.go | 365 -------- 19 files changed, 16 insertions(+), 4136 deletions(-) rename {sdk => internal}/apply/manager.go (99%) rename {sdk => internal}/apply/manager_test.go (99%) rename {sdk => internal}/apply/planner.go (98%) rename {sdk/internal => internal}/apply/planner_test.go (99%) rename {sdk => internal}/apply/types.go (99%) rename {sdk => internal}/config/example_test.go (100%) rename {sdk => internal}/config/parser.go (100%) rename {sdk => internal}/config/parser_test.go (100%) rename {sdk => internal}/config/types.go (100%) delete mode 100644 sdk/apply/planner_test.go delete mode 100644 sdk/internal/apply/manager.go delete mode 100644 sdk/internal/apply/manager_test.go delete mode 100644 sdk/internal/apply/planner.go delete mode 100644 sdk/internal/apply/types.go delete mode 100644 sdk/internal/config/example_test.go delete mode 100644 sdk/internal/config/parser.go delete mode 100644 sdk/internal/config/parser_test.go delete mode 100644 sdk/internal/config/types.go diff --git a/cmd/apply.go b/cmd/apply.go index 4c2acf7..ca2e2fd 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -9,8 +9,8 @@ import ( "path/filepath" "strings" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/apply" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "github.com/spf13/cobra" ) diff --git a/sdk/apply/manager.go b/internal/apply/manager.go similarity index 99% rename from sdk/apply/manager.go rename to internal/apply/manager.go index fecff21..5420352 100644 --- a/sdk/apply/manager.go +++ b/internal/apply/manager.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" ) diff --git a/sdk/apply/manager_test.go b/internal/apply/manager_test.go similarity index 99% rename from sdk/apply/manager_test.go rename to internal/apply/manager_test.go index 17ae9d5..5055200 100644 --- a/sdk/apply/manager_test.go +++ b/internal/apply/manager_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -239,7 +239,7 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { require.NotNil(t, result) assert.False(t, result.Success) assert.Len(t, result.CompletedActions, 1) // App was created - assert.Len(t, result.FailedActions, 1) // Instance failed + assert.Len(t, result.FailedActions, 1) // Instance failed assert.True(t, result.RollbackPerformed) assert.True(t, result.RollbackSuccess) assert.Contains(t, err.Error(), "instance actions failed") @@ -591,4 +591,4 @@ func TestCreateApplicationInput(t *testing.T) { assert.Len(t, capturedInput.App.RequiredOutboundConnections, 1) mockClient.AssertExpectations(t) -} \ No newline at end of file +} diff --git a/sdk/apply/planner.go b/internal/apply/planner.go similarity index 98% rename from sdk/apply/planner.go rename to internal/apply/planner.go index 718dde5..c1e2568 100644 --- a/sdk/apply/planner.go +++ b/internal/apply/planner.go @@ -11,8 +11,8 @@ import ( "strings" "time" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/config" ) // EdgeConnectClientInterface defines the methods needed for deployment planning @@ -131,7 +131,7 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E Version: config.Spec.GetAppVersion(), Organization: config.Spec.InfraTemplate[0].Organization, // Use first infra template for org Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region - Exists: false, // Will be set based on current state + Exists: false, // Will be set based on current state } if config.Spec.IsK8sApp() { @@ -453,8 +453,8 @@ func isResourceNotFoundError(err error) bool { errStr := strings.ToLower(err.Error()) return strings.Contains(errStr, "not found") || - strings.Contains(errStr, "does not exist") || - strings.Contains(errStr, "404") + strings.Contains(errStr, "does not exist") || + strings.Contains(errStr, "404") } // max returns the larger of two durations @@ -468,4 +468,4 @@ func max(a, b time.Duration) time.Duration { // getInstanceName generates the instance name following the pattern: appName-appVersion-instance func getInstanceName(appName, appVersion string) string { return fmt.Sprintf("%s-%s-instance", appName, appVersion) -} \ No newline at end of file +} diff --git a/sdk/internal/apply/planner_test.go b/internal/apply/planner_test.go similarity index 99% rename from sdk/internal/apply/planner_test.go rename to internal/apply/planner_test.go index a5c5615..5855b06 100644 --- a/sdk/internal/apply/planner_test.go +++ b/internal/apply/planner_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -425,7 +425,7 @@ func TestDeploymentPlanMethods(t *testing.T) { plan := &DeploymentPlan{ ConfigName: "test-plan", AppAction: AppAction{ - Type: ActionCreate, + Type: ActionCreate, Desired: &AppState{Name: "test-app"}, }, InstanceActions: []InstanceAction{ @@ -550,4 +550,4 @@ func TestPlanErrorHandling(t *testing.T) { assert.Contains(t, err.Error(), "failed to query current app state") mockClient.AssertExpectations(t) -} \ No newline at end of file +} diff --git a/sdk/apply/types.go b/internal/apply/types.go similarity index 99% rename from sdk/apply/types.go rename to internal/apply/types.go index d86900b..a958d8e 100644 --- a/sdk/apply/types.go +++ b/internal/apply/types.go @@ -6,7 +6,7 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" ) // ActionType represents the type of action to be performed @@ -425,4 +425,4 @@ func (dp *DeploymentPlan) Clone() *DeploymentPlan { copy(clone.InstanceActions, dp.InstanceActions) return clone -} \ No newline at end of file +} diff --git a/sdk/config/example_test.go b/internal/config/example_test.go similarity index 100% rename from sdk/config/example_test.go rename to internal/config/example_test.go diff --git a/sdk/config/parser.go b/internal/config/parser.go similarity index 100% rename from sdk/config/parser.go rename to internal/config/parser.go diff --git a/sdk/config/parser_test.go b/internal/config/parser_test.go similarity index 100% rename from sdk/config/parser_test.go rename to internal/config/parser_test.go diff --git a/sdk/config/types.go b/internal/config/types.go similarity index 100% rename from sdk/config/types.go rename to internal/config/types.go diff --git a/sdk/apply/planner_test.go b/sdk/apply/planner_test.go deleted file mode 100644 index 478a32a..0000000 --- a/sdk/apply/planner_test.go +++ /dev/null @@ -1,553 +0,0 @@ -// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios -// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios -package apply - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/config" - "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 edgeconnect.AppKey, region string) (edgeconnect.App, error) { - args := m.Called(ctx, appKey, region) - if args.Get(0) == nil { - return edgeconnect.App{}, args.Error(1) - } - return args.Get(0).(edgeconnect.App), args.Error(1) -} - -func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) { - args := m.Called(ctx, instanceKey, region) - if args.Get(0) == nil { - return edgeconnect.AppInstance{}, args.Error(1) - } - return args.Get(0).(edgeconnect.AppInstance), args.Error(1) -} - -func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { - args := m.Called(ctx, input) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error { - args := m.Called(ctx, input) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error { - args := m.Called(ctx, appKey, region) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error { - args := m.Called(ctx, instanceKey, region) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect.AppKey, region string) ([]edgeconnect.App, error) { - args := m.Called(ctx, appKey, region) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]edgeconnect.App), args.Error(1) -} - -func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) { - args := m.Called(ctx, instanceKey, region) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]edgeconnect.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", - }, - Spec: config.Spec{ - K8sApp: &config.K8sApp{ - AppName: "test-app", - AppVersion: "1.0.0", - ManifestFile: manifestFile, - }, - InfraTemplate: []config.InfraTemplate{ - { - Organization: "testorg", - 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("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) - - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). - Return(nil, &edgeconnect.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 - existingApp := &edgeconnect.App{ - Key: edgeconnect.AppKey{ - Organization: "testorg", - Name: "test-app", - Version: "1.0.0", - }, - Deployment: "kubernetes", - // Note: Manifest hash tracking would be implemented when API supports annotations - } - - // Mock existing instance - existingInstance := &edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ - Organization: "testorg", - Name: "test-app-1.0.0-instance", - CloudletKey: edgeconnect.CloudletKey{ - Organization: "TestCloudletOrg", - Name: "TestCloudlet", - }, - }, - AppKey: edgeconnect.AppKey{ - Organization: "testorg", - Name: "test-app", - Version: "1.0.0", - }, - Flavor: edgeconnect.Flavor{ - Name: "small", - }, - State: "Ready", - PowerState: "PowerOn", - } - - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(*existingApp, nil) - - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.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 TestPlanManifestChanged(t *testing.T) { - // Skip this test for now since manifest hash comparison isn't implemented yet - // due to EdgeConnect API not supporting annotations - t.Skip("Manifest hash comparison not implemented - waiting for API support for annotations") -} - -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{ - Organization: "testorg", - Region: "EU", - CloudletOrg: "EUCloudletOrg", - CloudletName: "EUCloudlet", - FlavorName: "medium", - }) - - // Mock API calls to return "not found" errors - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) - - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) - - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "EU"). - Return(nil, &edgeconnect.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 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", &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true}, - {"does not exist error", &edgeconnect.APIError{Messages: []string{"App does not exist"}}, true}, - {"404 in message", &edgeconnect.APIError{Messages: []string{"HTTP 404 error"}}, true}, - {"other error", &edgeconnect.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("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.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) -} \ No newline at end of file diff --git a/sdk/internal/apply/manager.go b/sdk/internal/apply/manager.go deleted file mode 100644 index 9d8d823..0000000 --- a/sdk/internal/apply/manager.go +++ /dev/null @@ -1,542 +0,0 @@ -// ABOUTME: Resource management for EdgeConnect apply command with deployment execution and rollback -// ABOUTME: Handles actual deployment operations, manifest processing, and error recovery with parallel execution -package apply - -import ( - "context" - "fmt" - "io" - "os" - "sync" - "time" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/config" -) - -// ResourceManagerInterface defines the interface for resource management -type ResourceManagerInterface interface { - // ApplyDeployment executes a deployment plan - ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig) (*ExecutionResult, error) - - // RollbackDeployment attempts to rollback a failed deployment - RollbackDeployment(ctx context.Context, result *ExecutionResult) error - - // ValidatePrerequisites checks if deployment prerequisites are met - ValidatePrerequisites(ctx context.Context, plan *DeploymentPlan) error -} - -// EdgeConnectResourceManager implements resource management for EdgeConnect -type EdgeConnectResourceManager struct { - client EdgeConnectClientInterface - parallelLimit int - rollbackOnFail bool - logger Logger -} - -// Logger interface for deployment logging -type Logger interface { - Printf(format string, v ...interface{}) -} - -// ResourceManagerOptions configures the resource manager behavior -type ResourceManagerOptions struct { - // ParallelLimit controls how many operations run concurrently - ParallelLimit int - - // RollbackOnFail automatically rolls back on deployment failure - RollbackOnFail bool - - // Logger for deployment operations - Logger Logger - - // Timeout for individual operations - OperationTimeout time.Duration -} - -// DefaultResourceManagerOptions returns sensible defaults -func DefaultResourceManagerOptions() ResourceManagerOptions { - return ResourceManagerOptions{ - ParallelLimit: 5, // Conservative parallel limit - RollbackOnFail: true, - OperationTimeout: 2 * time.Minute, - } -} - -// NewResourceManager creates a new EdgeConnect resource manager -func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface { - options := DefaultResourceManagerOptions() - for _, opt := range opts { - opt(&options) - } - - return &EdgeConnectResourceManager{ - client: client, - parallelLimit: options.ParallelLimit, - rollbackOnFail: options.RollbackOnFail, - logger: options.Logger, - } -} - -// WithParallelLimit sets the parallel execution limit -func WithParallelLimit(limit int) func(*ResourceManagerOptions) { - return func(opts *ResourceManagerOptions) { - opts.ParallelLimit = limit - } -} - -// WithRollbackOnFail enables/disables automatic rollback -func WithRollbackOnFail(rollback bool) func(*ResourceManagerOptions) { - return func(opts *ResourceManagerOptions) { - opts.RollbackOnFail = rollback - } -} - -// WithLogger sets a logger for deployment operations -func WithLogger(logger Logger) func(*ResourceManagerOptions) { - return func(opts *ResourceManagerOptions) { - opts.Logger = logger - } -} - -// ApplyDeployment executes a deployment plan with rollback support -func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig) (*ExecutionResult, error) { - startTime := time.Now() - rm.logf("Starting deployment: %s", plan.ConfigName) - - result := &ExecutionResult{ - Plan: plan, - CompletedActions: []ActionResult{}, - FailedActions: []ActionResult{}, - } - - // Step 1: Validate prerequisites - if err := rm.ValidatePrerequisites(ctx, plan); err != nil { - result.Error = fmt.Errorf("prerequisites validation failed: %w", err) - result.Duration = time.Since(startTime) - return result, err - } - - // Step 2: Execute app action first (apps must exist before instances) - if plan.AppAction.Type != ActionNone { - appResult := rm.executeAppAction(ctx, plan.AppAction, config) - if appResult.Success { - result.CompletedActions = append(result.CompletedActions, appResult) - rm.logf("App action completed: %s", appResult.Type) - } else { - result.FailedActions = append(result.FailedActions, appResult) - rm.logf("App action failed: %s - %v", appResult.Type, appResult.Error) - - if rm.rollbackOnFail { - rm.logf("Attempting rollback...") - if rollbackErr := rm.RollbackDeployment(ctx, result); rollbackErr != nil { - rm.logf("Rollback failed: %v", rollbackErr) - } else { - result.RollbackPerformed = true - result.RollbackSuccess = true - } - } - - result.Error = appResult.Error - result.Duration = time.Since(startTime) - return result, appResult.Error - } - } - - // Step 3: Execute instance actions in parallel - instanceResults := rm.executeInstanceActions(ctx, plan.InstanceActions, config) - - for _, instanceResult := range instanceResults { - if instanceResult.Success { - result.CompletedActions = append(result.CompletedActions, instanceResult) - } else { - result.FailedActions = append(result.FailedActions, instanceResult) - } - } - - // Check if deployment succeeded - result.Success = len(result.FailedActions) == 0 - result.Duration = time.Since(startTime) - - if !result.Success { - result.Error = fmt.Errorf("%d instance actions failed", len(result.FailedActions)) - - if rm.rollbackOnFail { - rm.logf("Deployment failed, attempting rollback...") - if rollbackErr := rm.RollbackDeployment(ctx, result); rollbackErr != nil { - rm.logf("Rollback failed: %v", rollbackErr) - } else { - result.RollbackPerformed = true - result.RollbackSuccess = true - } - } - } else { - rm.logf("Deployment completed successfully in %v", result.Duration) - } - - return result, result.Error -} - -// executeAppAction handles application creation/update operations -func (rm *EdgeConnectResourceManager) executeAppAction(ctx context.Context, action AppAction, config *config.EdgeConnectConfig) ActionResult { - startTime := time.Now() - result := ActionResult{ - Type: action.Type, - Target: action.Desired.Name, - } - - switch action.Type { - case ActionCreate: - result.Success, result.Error = rm.createApplication(ctx, action, config) - result.Details = fmt.Sprintf("Created application %s version %s", action.Desired.Name, action.Desired.Version) - - case ActionUpdate: - result.Success, result.Error = rm.updateApplication(ctx, action, config) - result.Details = fmt.Sprintf("Updated application %s version %s", action.Desired.Name, action.Desired.Version) - - default: - result.Success = true - result.Details = "No action required" - } - - result.Duration = time.Since(startTime) - return result -} - -// executeInstanceActions handles instance deployment across multiple cloudlets in parallel -func (rm *EdgeConnectResourceManager) executeInstanceActions(ctx context.Context, actions []InstanceAction, config *config.EdgeConnectConfig) []ActionResult { - if len(actions) == 0 { - return []ActionResult{} - } - - // Create semaphore to limit parallel operations - semaphore := make(chan struct{}, rm.parallelLimit) - results := make([]ActionResult, len(actions)) - var wg sync.WaitGroup - - for i, action := range actions { - if action.Type == ActionNone { - results[i] = ActionResult{ - Type: action.Type, - Target: action.InstanceName, - Success: true, - Details: "No action required", - } - continue - } - - wg.Add(1) - go func(index int, instanceAction InstanceAction) { - defer wg.Done() - - // Acquire semaphore - semaphore <- struct{}{} - defer func() { <-semaphore }() - - results[index] = rm.executeInstanceAction(ctx, instanceAction, config) - }(i, action) - } - - wg.Wait() - return results -} - -// executeInstanceAction handles single instance operations -func (rm *EdgeConnectResourceManager) executeInstanceAction(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) ActionResult { - startTime := time.Now() - result := ActionResult{ - Type: action.Type, - Target: action.InstanceName, - } - - switch action.Type { - case ActionCreate: - result.Success, result.Error = rm.createInstance(ctx, action, config) - result.Details = fmt.Sprintf("Created instance %s on %s:%s", - action.InstanceName, action.Target.CloudletOrg, action.Target.CloudletName) - - case ActionUpdate: - result.Success, result.Error = rm.updateInstance(ctx, action, config) - result.Details = fmt.Sprintf("Updated instance %s", action.InstanceName) - - default: - result.Success = true - result.Details = "No action required" - } - - result.Duration = time.Since(startTime) - return result -} - -// createApplication creates a new application with manifest file processing -func (rm *EdgeConnectResourceManager) createApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig) (bool, error) { - // Read and process manifest file - manifestContent, err := rm.readManifestFile(config.Spec.GetManifestFile()) - if err != nil { - return false, fmt.Errorf("failed to read manifest file: %w", err) - } - - // Build the app input - appInput := &edgeconnect.NewAppInput{ - Region: action.Desired.Region, - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ - Organization: action.Desired.Organization, - Name: action.Desired.Name, - Version: action.Desired.Version, - }, - Deployment: rm.getDeploymentType(config), - ImageType: "ImageTypeDocker", // Default for EdgeConnect - ImagePath: rm.getImagePath(config), - AllowServerless: true, // Required for Kubernetes - DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, - ServerlessConfig: struct{}{}, // Required empty struct - DeploymentManifest: manifestContent, - }, - } - - // Add network configuration if specified - if config.Spec.Network != nil { - appInput.App.RequiredOutboundConnections = rm.convertNetworkRules(config.Spec.Network) - } - - // Create the application - if client, ok := rm.client.(interface { - CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error - }); ok { - if err := client.CreateApp(ctx, appInput); err != nil { - return false, fmt.Errorf("failed to create application: %w", err) - } - } else { - return false, fmt.Errorf("client does not support CreateApp operation") - } - - rm.logf("Successfully created application: %s/%s version %s", - action.Desired.Organization, action.Desired.Name, action.Desired.Version) - - return true, nil -} - -// updateApplication updates an existing application -func (rm *EdgeConnectResourceManager) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig) (bool, error) { - // For now, EdgeConnect doesn't support app updates directly - // This would be implemented when the API supports app updates - rm.logf("Application update not yet supported by EdgeConnect API") - return true, nil -} - -// createInstance creates a new application instance -func (rm *EdgeConnectResourceManager) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) { - instanceInput := &edgeconnect.NewAppInstanceInput{ - Region: action.Target.Region, - AppInst: edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ - Organization: action.Target.Organization, - Name: action.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ - Organization: action.Target.CloudletOrg, - Name: action.Target.CloudletName, - }, - }, - AppKey: edgeconnect.AppKey{ - Organization: action.Target.Organization, - Name: config.Spec.GetAppName(), - Version: config.Spec.GetAppVersion(), - }, - Flavor: edgeconnect.Flavor{ - Name: action.Target.FlavorName, - }, - }, - } - - // Create the instance - if client, ok := rm.client.(interface { - CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error - }); ok { - if err := client.CreateAppInstance(ctx, instanceInput); err != nil { - return false, fmt.Errorf("failed to create instance: %w", err) - } - } else { - return false, fmt.Errorf("client does not support CreateAppInstance operation") - } - - rm.logf("Successfully created instance: %s on %s:%s", - action.InstanceName, action.Target.CloudletOrg, action.Target.CloudletName) - - return true, nil -} - -// updateInstance updates an existing application instance -func (rm *EdgeConnectResourceManager) updateInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) { - // For now, instance updates would require delete/recreate - // This would be optimized when the API supports direct instance updates - rm.logf("Instance update requires recreate - not yet optimized") - return true, nil -} - -// readManifestFile reads and returns the contents of a manifest file -func (rm *EdgeConnectResourceManager) readManifestFile(manifestPath string) (string, error) { - if manifestPath == "" { - return "", nil - } - - file, err := os.Open(manifestPath) - if err != nil { - return "", fmt.Errorf("failed to open manifest file %s: %w", manifestPath, err) - } - defer file.Close() - - content, err := io.ReadAll(file) - if err != nil { - return "", fmt.Errorf("failed to read manifest file %s: %w", manifestPath, err) - } - - return string(content), nil -} - -// getDeploymentType determines the deployment type from config -func (rm *EdgeConnectResourceManager) getDeploymentType(config *config.EdgeConnectConfig) string { - if config.Spec.IsK8sApp() { - return "kubernetes" - } - return "docker" -} - -// getImagePath gets the image path for the application -func (rm *EdgeConnectResourceManager) getImagePath(config *config.EdgeConnectConfig) string { - if config.Spec.IsDockerApp() && config.Spec.DockerApp.Image != "" { - return config.Spec.DockerApp.Image - } - // Default for kubernetes apps - return "https://registry-1.docker.io/library/nginx:latest" -} - -// convertNetworkRules converts config network rules to EdgeConnect SecurityRules -func (rm *EdgeConnectResourceManager) convertNetworkRules(network *config.NetworkConfig) []edgeconnect.SecurityRule { - rules := make([]edgeconnect.SecurityRule, len(network.OutboundConnections)) - - for i, conn := range network.OutboundConnections { - rules[i] = edgeconnect.SecurityRule{ - Protocol: conn.Protocol, - PortRangeMin: conn.PortRangeMin, - PortRangeMax: conn.PortRangeMax, - RemoteCIDR: conn.RemoteCIDR, - } - } - - return rules -} - -// ValidatePrerequisites checks if deployment prerequisites are met -func (rm *EdgeConnectResourceManager) ValidatePrerequisites(ctx context.Context, plan *DeploymentPlan) error { - rm.logf("Validating deployment prerequisites for: %s", plan.ConfigName) - - // Check if we have any actions to perform - if plan.IsEmpty() { - return fmt.Errorf("deployment plan is empty - no actions to perform") - } - - // Validate that we have required client capabilities - if rm.client == nil { - return fmt.Errorf("EdgeConnect client is not configured") - } - - rm.logf("Prerequisites validation passed") - return nil -} - -// RollbackDeployment attempts to rollback a failed deployment -func (rm *EdgeConnectResourceManager) RollbackDeployment(ctx context.Context, result *ExecutionResult) error { - rm.logf("Starting rollback for deployment: %s", result.Plan.ConfigName) - - rollbackErrors := []error{} - - // Rollback completed instances (in reverse order) - for i := len(result.CompletedActions) - 1; i >= 0; i-- { - action := result.CompletedActions[i] - - switch action.Type { - case ActionCreate: - if err := rm.rollbackCreateAction(ctx, action, result.Plan); err != nil { - rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to rollback %s: %w", action.Target, err)) - } else { - rm.logf("Successfully rolled back: %s", action.Target) - } - } - } - - if len(rollbackErrors) > 0 { - return fmt.Errorf("rollback encountered %d errors: %v", len(rollbackErrors), rollbackErrors) - } - - rm.logf("Rollback completed successfully") - return nil -} - -// rollbackCreateAction rolls back a CREATE action by deleting the resource -func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context, action ActionResult, plan *DeploymentPlan) error { - if action.Type != ActionCreate { - return nil - } - - // Determine if this is an app or instance rollback based on the target name - isInstance := false - for _, instanceAction := range plan.InstanceActions { - if instanceAction.InstanceName == action.Target { - isInstance = true - break - } - } - - if isInstance { - return rm.rollbackInstance(ctx, action, plan) - } else { - return rm.rollbackApp(ctx, action, plan) - } -} - -// rollbackApp deletes an application that was created -func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error { - if client, ok := rm.client.(interface { - DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error - }); ok { - appKey := edgeconnect.AppKey{ - Organization: plan.AppAction.Desired.Organization, - Name: plan.AppAction.Desired.Name, - Version: plan.AppAction.Desired.Version, - } - return client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region) - } - return fmt.Errorf("client does not support DeleteApp operation") -} - -// rollbackInstance deletes an instance that was created -func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, action ActionResult, plan *DeploymentPlan) error { - if client, ok := rm.client.(interface { - DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error - }); ok { - // Find the instance action to get the details - for _, instanceAction := range plan.InstanceActions { - if instanceAction.InstanceName == action.Target { - instanceKey := edgeconnect.AppInstanceKey{ - Organization: instanceAction.Target.Organization, - Name: instanceAction.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ - Organization: instanceAction.Target.CloudletOrg, - Name: instanceAction.Target.CloudletName, - }, - } - return client.DeleteAppInstance(ctx, instanceKey, instanceAction.Target.Region) - } - } - return fmt.Errorf("instance action not found for rollback: %s", action.Target) - } - return fmt.Errorf("client does not support DeleteAppInstance operation") -} - -// logf logs a message if a logger is configured -func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) { - if rm.logger != nil { - rm.logger.Printf("[ResourceManager] "+format, v...) - } -} \ No newline at end of file diff --git a/sdk/internal/apply/manager_test.go b/sdk/internal/apply/manager_test.go deleted file mode 100644 index 3771332..0000000 --- a/sdk/internal/apply/manager_test.go +++ /dev/null @@ -1,594 +0,0 @@ -// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios -// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients -package apply - -import ( - "context" - "fmt" - "os" - "path/filepath" - "testing" - "time" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/config" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -// MockResourceClient extends MockEdgeConnectClient with resource management methods -type MockResourceClient struct { - MockEdgeConnectClient -} - -func (m *MockResourceClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { - args := m.Called(ctx, input) - return args.Error(0) -} - -func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error { - args := m.Called(ctx, input) - return args.Error(0) -} - -func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error { - args := m.Called(ctx, appKey, region) - return args.Error(0) -} - -func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error { - args := m.Called(ctx, instanceKey, region) - return args.Error(0) -} - -// TestLogger implements Logger interface for testing -type TestLogger struct { - messages []string -} - -func (l *TestLogger) Printf(format string, v ...interface{}) { - l.messages = append(l.messages, fmt.Sprintf(format, v...)) -} - -func TestNewResourceManager(t *testing.T) { - mockClient := &MockResourceClient{} - manager := NewResourceManager(mockClient) - - assert.NotNil(t, manager) - assert.IsType(t, &EdgeConnectResourceManager{}, manager) -} - -func TestDefaultResourceManagerOptions(t *testing.T) { - opts := DefaultResourceManagerOptions() - - assert.Equal(t, 5, opts.ParallelLimit) - assert.True(t, opts.RollbackOnFail) - assert.Equal(t, 2*time.Minute, opts.OperationTimeout) -} - -func TestWithOptions(t *testing.T) { - mockClient := &MockResourceClient{} - logger := &TestLogger{} - - manager := NewResourceManager(mockClient, - WithParallelLimit(10), - WithRollbackOnFail(false), - WithLogger(logger), - ) - - // Cast to implementation to check options were applied - impl := manager.(*EdgeConnectResourceManager) - assert.Equal(t, 10, impl.parallelLimit) - assert.False(t, impl.rollbackOnFail) - assert.Equal(t, logger, impl.logger) -} - -func createTestDeploymentPlan() *DeploymentPlan { - return &DeploymentPlan{ - ConfigName: "test-deployment", - AppAction: AppAction{ - Type: ActionCreate, - Desired: &AppState{ - Name: "test-app", - Version: "1.0.0", - Organization: "testorg", - Region: "US", - }, - }, - InstanceActions: []InstanceAction{ - { - Type: ActionCreate, - Target: config.InfraTemplate{ - Organization: "testorg", - Region: "US", - CloudletOrg: "cloudletorg", - CloudletName: "cloudlet1", - FlavorName: "small", - }, - Desired: &InstanceState{ - Name: "test-app-1.0.0-instance", - AppName: "test-app", - }, - InstanceName: "test-app-1.0.0-instance", - }, - }, - } -} - -func createTestManagerConfig(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", - }, - Spec: config.Spec{ - K8sApp: &config.K8sApp{ - AppName: "test-app", - AppVersion: "1.0.0", - ManifestFile: manifestFile, - }, - InfraTemplate: []config.InfraTemplate{ - { - Organization: "testorg", - Region: "US", - CloudletOrg: "cloudletorg", - CloudletName: "cloudlet1", - FlavorName: "small", - }, - }, - Network: &config.NetworkConfig{ - OutboundConnections: []config.OutboundConnection{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - }, - }, - }, - } -} - -func TestApplyDeploymentSuccess(t *testing.T) { - mockClient := &MockResourceClient{} - logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger)) - - plan := createTestDeploymentPlan() - config := createTestManagerConfig(t) - - // Mock successful operations - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). - Return(nil) - mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). - Return(nil) - - ctx := context.Background() - result, err := manager.ApplyDeployment(ctx, plan, config) - - require.NoError(t, err) - require.NotNil(t, result) - assert.True(t, result.Success) - assert.Len(t, result.CompletedActions, 2) // 1 app + 1 instance - assert.Len(t, result.FailedActions, 0) - assert.False(t, result.RollbackPerformed) - assert.Greater(t, result.Duration, time.Duration(0)) - - // Check that operations were logged - assert.Greater(t, len(logger.messages), 0) - - mockClient.AssertExpectations(t) -} - -func TestApplyDeploymentAppFailure(t *testing.T) { - mockClient := &MockResourceClient{} - logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger)) - - plan := createTestDeploymentPlan() - config := createTestManagerConfig(t) - - // Mock app creation failure - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) - - ctx := context.Background() - result, err := manager.ApplyDeployment(ctx, plan, config) - - require.Error(t, err) - require.NotNil(t, result) - assert.False(t, result.Success) - assert.Len(t, result.CompletedActions, 0) - assert.Len(t, result.FailedActions, 1) - assert.Contains(t, err.Error(), "failed to create application") - - mockClient.AssertExpectations(t) -} - -func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { - mockClient := &MockResourceClient{} - logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true)) - - plan := createTestDeploymentPlan() - config := createTestManagerConfig(t) - - // Mock successful app creation but failed instance creation - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). - Return(nil) - mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}}) - - // Mock rollback operations - mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil) - - ctx := context.Background() - result, err := manager.ApplyDeployment(ctx, plan, config) - - require.Error(t, err) - require.NotNil(t, result) - assert.False(t, result.Success) - assert.Len(t, result.CompletedActions, 1) // App was created - assert.Len(t, result.FailedActions, 1) // Instance failed - assert.True(t, result.RollbackPerformed) - assert.True(t, result.RollbackSuccess) - assert.Contains(t, err.Error(), "instance actions failed") - - mockClient.AssertExpectations(t) -} - -func TestApplyDeploymentNoActions(t *testing.T) { - mockClient := &MockResourceClient{} - manager := NewResourceManager(mockClient) - - // Create empty plan - plan := &DeploymentPlan{ - ConfigName: "empty-plan", - AppAction: AppAction{Type: ActionNone}, - } - config := createTestManagerConfig(t) - - ctx := context.Background() - result, err := manager.ApplyDeployment(ctx, plan, config) - - require.Error(t, err) - require.NotNil(t, result) - assert.Contains(t, err.Error(), "deployment plan is empty") - - mockClient.AssertNotCalled(t, "CreateApp") - mockClient.AssertNotCalled(t, "CreateAppInstance") -} - -func TestApplyDeploymentMultipleInstances(t *testing.T) { - mockClient := &MockResourceClient{} - logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2)) - - // Create plan with multiple instances - plan := &DeploymentPlan{ - ConfigName: "multi-instance", - AppAction: AppAction{ - Type: ActionCreate, - Desired: &AppState{ - Name: "test-app", - Version: "1.0.0", - Organization: "testorg", - Region: "US", - }, - }, - InstanceActions: []InstanceAction{ - { - Type: ActionCreate, - Target: config.InfraTemplate{ - Organization: "testorg", - Region: "US", - CloudletOrg: "cloudletorg1", - CloudletName: "cloudlet1", - FlavorName: "small", - }, - Desired: &InstanceState{Name: "instance1"}, - InstanceName: "instance1", - }, - { - Type: ActionCreate, - Target: config.InfraTemplate{ - Organization: "testorg", - Region: "EU", - CloudletOrg: "cloudletorg2", - CloudletName: "cloudlet2", - FlavorName: "medium", - }, - Desired: &InstanceState{Name: "instance2"}, - InstanceName: "instance2", - }, - }, - } - - config := createTestManagerConfig(t) - - // Mock successful operations - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). - Return(nil) - mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). - Return(nil) - - ctx := context.Background() - result, err := manager.ApplyDeployment(ctx, plan, config) - - require.NoError(t, err) - require.NotNil(t, result) - assert.True(t, result.Success) - assert.Len(t, result.CompletedActions, 3) // 1 app + 2 instances - assert.Len(t, result.FailedActions, 0) - - mockClient.AssertExpectations(t) -} - -func TestValidatePrerequisites(t *testing.T) { - mockClient := &MockResourceClient{} - manager := NewResourceManager(mockClient) - - tests := []struct { - name string - plan *DeploymentPlan - wantErr bool - errMsg string - }{ - { - name: "valid plan", - plan: &DeploymentPlan{ - ConfigName: "test", - AppAction: AppAction{Type: ActionCreate, Desired: &AppState{}}, - }, - wantErr: false, - }, - { - name: "empty plan", - plan: &DeploymentPlan{ - ConfigName: "test", - AppAction: AppAction{Type: ActionNone}, - }, - wantErr: true, - errMsg: "deployment plan is empty", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - err := manager.ValidatePrerequisites(ctx, tt.plan) - - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestRollbackDeployment(t *testing.T) { - mockClient := &MockResourceClient{} - logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger)) - - // Create result with completed actions - plan := createTestDeploymentPlan() - result := &ExecutionResult{ - Plan: plan, - CompletedActions: []ActionResult{ - { - Type: ActionCreate, - Target: "test-app", - Success: true, - }, - { - Type: ActionCreate, - Target: "test-app-1.0.0-instance", - Success: true, - }, - }, - FailedActions: []ActionResult{}, - } - - // Mock rollback operations - mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). - Return(nil) - mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil) - - ctx := context.Background() - err := manager.RollbackDeployment(ctx, result) - - require.NoError(t, err) - mockClient.AssertExpectations(t) - - // Check rollback was logged - assert.Greater(t, len(logger.messages), 0) -} - -func TestRollbackDeploymentFailure(t *testing.T) { - mockClient := &MockResourceClient{} - manager := NewResourceManager(mockClient) - - plan := createTestDeploymentPlan() - result := &ExecutionResult{ - Plan: plan, - CompletedActions: []ActionResult{ - { - Type: ActionCreate, - Target: "test-app", - Success: true, - }, - }, - } - - // Mock rollback failure - mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Delete failed"}}) - - ctx := context.Background() - err := manager.RollbackDeployment(ctx, result) - - require.Error(t, err) - assert.Contains(t, err.Error(), "rollback encountered") - mockClient.AssertExpectations(t) -} - -func TestReadManifestFile(t *testing.T) { - manager := &EdgeConnectResourceManager{} - tempDir := t.TempDir() - - // Create test file - testFile := filepath.Join(tempDir, "test.yaml") - expectedContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" - err := os.WriteFile(testFile, []byte(expectedContent), 0644) - require.NoError(t, err) - - content, err := manager.readManifestFile(testFile) - require.NoError(t, err) - assert.Equal(t, expectedContent, content) - - // Test empty path - content, err = manager.readManifestFile("") - require.NoError(t, err) - assert.Empty(t, content) - - // Test non-existent file - _, err = manager.readManifestFile("/non/existent/file") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to open manifest file") -} - -func TestGetDeploymentType(t *testing.T) { - manager := &EdgeConnectResourceManager{} - - // Test k8s app - k8sConfig := &config.EdgeConnectConfig{ - Spec: config.Spec{ - K8sApp: &config.K8sApp{}, - }, - } - assert.Equal(t, "kubernetes", manager.getDeploymentType(k8sConfig)) - - // Test docker app - dockerConfig := &config.EdgeConnectConfig{ - Spec: config.Spec{ - DockerApp: &config.DockerApp{}, - }, - } - assert.Equal(t, "docker", manager.getDeploymentType(dockerConfig)) -} - -func TestGetImagePath(t *testing.T) { - manager := &EdgeConnectResourceManager{} - - // Test docker app with image - dockerConfig := &config.EdgeConnectConfig{ - Spec: config.Spec{ - DockerApp: &config.DockerApp{ - Image: "my-custom-image:latest", - }, - }, - } - assert.Equal(t, "my-custom-image:latest", manager.getImagePath(dockerConfig)) - - // Test k8s app (should use default) - k8sConfig := &config.EdgeConnectConfig{ - Spec: config.Spec{ - K8sApp: &config.K8sApp{}, - }, - } - assert.Equal(t, "https://registry-1.docker.io/library/nginx:latest", manager.getImagePath(k8sConfig)) -} - -func TestConvertNetworkRules(t *testing.T) { - manager := &EdgeConnectResourceManager{} - - network := &config.NetworkConfig{ - OutboundConnections: []config.OutboundConnection{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - { - Protocol: "tcp", - PortRangeMin: 443, - PortRangeMax: 443, - RemoteCIDR: "10.0.0.0/8", - }, - }, - } - - rules := manager.convertNetworkRules(network) - require.Len(t, rules, 2) - - assert.Equal(t, "tcp", rules[0].Protocol) - assert.Equal(t, 80, rules[0].PortRangeMin) - assert.Equal(t, 80, rules[0].PortRangeMax) - assert.Equal(t, "0.0.0.0/0", rules[0].RemoteCIDR) - - assert.Equal(t, "tcp", rules[1].Protocol) - assert.Equal(t, 443, rules[1].PortRangeMin) - assert.Equal(t, 443, rules[1].PortRangeMax) - assert.Equal(t, "10.0.0.0/8", rules[1].RemoteCIDR) -} - -func TestCreateApplicationInput(t *testing.T) { - mockClient := &MockResourceClient{} - manager := NewResourceManager(mockClient) - - config := createTestManagerConfig(t) - action := AppAction{ - Type: ActionCreate, - Desired: &AppState{ - Name: "test-app", - Version: "1.0.0", - Organization: "testorg", - Region: "US", - }, - } - - // Capture the input passed to CreateApp - var capturedInput *edgeconnect.NewAppInput - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). - Run(func(args mock.Arguments) { - capturedInput = args.Get(1).(*edgeconnect.NewAppInput) - }). - Return(nil) - - ctx := context.Background() - success, err := manager.(*EdgeConnectResourceManager).createApplication(ctx, action, config) - - require.NoError(t, err) - assert.True(t, success) - require.NotNil(t, capturedInput) - - // Verify the input was constructed correctly - assert.Equal(t, "US", capturedInput.Region) - assert.Equal(t, "testorg", capturedInput.App.Key.Organization) - assert.Equal(t, "test-app", capturedInput.App.Key.Name) - assert.Equal(t, "1.0.0", capturedInput.App.Key.Version) - assert.Equal(t, "kubernetes", capturedInput.App.Deployment) - assert.Equal(t, "ImageTypeDocker", capturedInput.App.ImageType) - assert.True(t, capturedInput.App.AllowServerless) - assert.NotEmpty(t, capturedInput.App.DeploymentManifest) - assert.Len(t, capturedInput.App.RequiredOutboundConnections, 1) - - mockClient.AssertExpectations(t) -} \ No newline at end of file diff --git a/sdk/internal/apply/planner.go b/sdk/internal/apply/planner.go deleted file mode 100644 index 6298f7a..0000000 --- a/sdk/internal/apply/planner.go +++ /dev/null @@ -1,471 +0,0 @@ -// ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison -// ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls -package apply - -import ( - "context" - "crypto/sha256" - "fmt" - "io" - "os" - "strings" - "time" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/config" -) - -// EdgeConnectClientInterface defines the methods needed for deployment planning -type EdgeConnectClientInterface interface { - ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) - ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) -} - -// Planner defines the interface for deployment planning -type Planner interface { - // Plan analyzes the configuration and current state to generate a deployment plan - Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) - - // PlanWithOptions allows customization of planning behavior - PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) -} - -// PlanOptions provides configuration for the planning process -type PlanOptions struct { - // DryRun indicates this is a planning-only operation - DryRun bool - - // Force indicates to proceed even with warnings - Force bool - - // SkipStateCheck bypasses current state queries (useful for testing) - SkipStateCheck bool - - // ParallelQueries enables parallel state fetching - ParallelQueries bool - - // Timeout for API operations - Timeout time.Duration -} - -// DefaultPlanOptions returns sensible default planning options -func DefaultPlanOptions() PlanOptions { - return PlanOptions{ - DryRun: false, - Force: false, - SkipStateCheck: false, - ParallelQueries: true, - Timeout: 30 * time.Second, - } -} - -// EdgeConnectPlanner implements the Planner interface for EdgeConnect -type EdgeConnectPlanner struct { - client EdgeConnectClientInterface -} - -// NewPlanner creates a new EdgeConnect deployment planner -func NewPlanner(client EdgeConnectClientInterface) Planner { - return &EdgeConnectPlanner{ - client: client, - } -} - -// Plan analyzes the configuration and generates a deployment plan -func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) { - return p.PlanWithOptions(ctx, config, DefaultPlanOptions()) -} - -// PlanWithOptions generates a deployment plan with custom options -func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) { - startTime := time.Now() - var warnings []string - - // Create the deployment plan structure - plan := &DeploymentPlan{ - ConfigName: config.Metadata.Name, - CreatedAt: startTime, - DryRun: opts.DryRun, - } - - // Step 1: Plan application state - appAction, appWarnings, err := p.planAppAction(ctx, config, opts) - if err != nil { - return &PlanResult{Error: err}, err - } - plan.AppAction = *appAction - warnings = append(warnings, appWarnings...) - - // Step 2: Plan instance actions - instanceActions, instanceWarnings, err := p.planInstanceActions(ctx, config, opts) - if err != nil { - return &PlanResult{Error: err}, err - } - plan.InstanceActions = instanceActions - warnings = append(warnings, instanceWarnings...) - - // Step 3: Calculate plan metadata - p.calculatePlanMetadata(plan) - - // Step 4: Generate summary - plan.Summary = plan.GenerateSummary() - - // Step 5: Validate the plan - if err := plan.Validate(); err != nil { - return &PlanResult{Error: fmt.Errorf("invalid deployment plan: %w", err)}, err - } - - return &PlanResult{ - Plan: plan, - Warnings: warnings, - }, nil -} - -// planAppAction determines what action needs to be taken for the application -func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*AppAction, []string, error) { - var warnings []string - - // Build desired app state - desired := &AppState{ - Name: config.Spec.GetAppName(), - Version: config.Spec.GetAppVersion(), - Organization: config.Spec.InfraTemplate[0].Organization, // Use first infra template for org - Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region - Exists: false, // Will be set based on current state - } - - if config.Spec.IsK8sApp() { - desired.AppType = AppTypeK8s - } else { - desired.AppType = AppTypeDocker - } - - // Calculate manifest hash - manifestHash, err := p.calculateManifestHash(config.Spec.GetManifestFile()) - if err != nil { - return nil, warnings, fmt.Errorf("failed to calculate manifest hash: %w", err) - } - desired.ManifestHash = manifestHash - - action := &AppAction{ - Type: ActionNone, - Desired: desired, - ManifestHash: manifestHash, - Reason: "No action needed", - } - - // Skip state check if requested (useful for testing) - if opts.SkipStateCheck { - action.Type = ActionCreate - action.Reason = "Creating app (state check skipped)" - action.Changes = []string{"Create new application"} - return action, warnings, nil - } - - // Query current app state - current, err := p.getCurrentAppState(ctx, desired, opts.Timeout) - if err != nil { - // If app doesn't exist, we need to create it - if isResourceNotFoundError(err) { - action.Type = ActionCreate - action.Reason = "Application does not exist" - action.Changes = []string{"Create new application"} - return action, warnings, nil - } - return nil, warnings, fmt.Errorf("failed to query current app state: %w", err) - } - - action.Current = current - - // Compare current vs desired state - changes, manifestChanged := p.compareAppStates(current, desired) - action.ManifestChanged = manifestChanged - - if len(changes) > 0 { - action.Type = ActionUpdate - action.Changes = changes - action.Reason = "Application configuration has changed" - - if manifestChanged { - warnings = append(warnings, "Manifest file has changed - instances may need to be recreated") - } - } - - return action, warnings, nil -} - -// planInstanceActions determines what actions need to be taken for instances -func (p *EdgeConnectPlanner) planInstanceActions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) ([]InstanceAction, []string, error) { - var actions []InstanceAction - var warnings []string - - for _, infra := range config.Spec.InfraTemplate { - instanceName := getInstanceName(config.Spec.GetAppName(), config.Spec.GetAppVersion()) - - desired := &InstanceState{ - Name: instanceName, - AppName: config.Spec.GetAppName(), - AppVersion: config.Spec.GetAppVersion(), - Organization: infra.Organization, - Region: infra.Region, - CloudletOrg: infra.CloudletOrg, - CloudletName: infra.CloudletName, - FlavorName: infra.FlavorName, - Exists: false, - } - - action := &InstanceAction{ - Type: ActionNone, - Target: infra, - Desired: desired, - InstanceName: instanceName, - Reason: "No action needed", - } - - // Skip state check if requested - if opts.SkipStateCheck { - action.Type = ActionCreate - action.Reason = "Creating instance (state check skipped)" - action.Changes = []string{"Create new instance"} - actions = append(actions, *action) - continue - } - - // Query current instance state - current, err := p.getCurrentInstanceState(ctx, desired, opts.Timeout) - if err != nil { - // If instance doesn't exist, we need to create it - if isResourceNotFoundError(err) { - action.Type = ActionCreate - action.Reason = "Instance does not exist" - action.Changes = []string{"Create new instance"} - actions = append(actions, *action) - continue - } - return nil, warnings, fmt.Errorf("failed to query current instance state: %w", err) - } - - action.Current = current - - // Compare current vs desired state - changes := p.compareInstanceStates(current, desired) - if len(changes) > 0 { - action.Type = ActionUpdate - action.Changes = changes - action.Reason = "Instance configuration has changed" - } - - actions = append(actions, *action) - } - - return actions, warnings, nil -} - -// getCurrentAppState queries the current state of an application -func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *AppState, timeout time.Duration) (*AppState, error) { - timeoutCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - appKey := edgeconnect.AppKey{ - Organization: desired.Organization, - Name: desired.Name, - Version: desired.Version, - } - - app, err := p.client.ShowApp(timeoutCtx, appKey, desired.Region) - if err != nil { - return nil, err - } - - current := &AppState{ - Name: app.Key.Name, - Version: app.Key.Version, - Organization: app.Key.Organization, - Region: desired.Region, - Exists: true, - LastUpdated: time.Now(), // EdgeConnect doesn't provide this, so use current time - } - - // Note: EdgeConnect API doesn't currently support annotations for manifest hash tracking - // This would be implemented when the API supports it - - // Determine app type based on deployment type - if app.Deployment == "kubernetes" { - current.AppType = AppTypeK8s - } else { - current.AppType = AppTypeDocker - } - - return current, nil -} - -// getCurrentInstanceState queries the current state of an application instance -func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desired *InstanceState, timeout time.Duration) (*InstanceState, error) { - timeoutCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - instanceKey := edgeconnect.AppInstanceKey{ - Organization: desired.Organization, - Name: desired.Name, - CloudletKey: edgeconnect.CloudletKey{ - Organization: desired.CloudletOrg, - Name: desired.CloudletName, - }, - } - - instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region) - if err != nil { - return nil, err - } - - current := &InstanceState{ - Name: instance.Key.Name, - AppName: instance.AppKey.Name, - AppVersion: instance.AppKey.Version, - Organization: instance.Key.Organization, - Region: desired.Region, - CloudletOrg: instance.Key.CloudletKey.Organization, - CloudletName: instance.Key.CloudletKey.Name, - FlavorName: instance.Flavor.Name, - State: instance.State, - PowerState: instance.PowerState, - Exists: true, - LastUpdated: time.Now(), // EdgeConnect doesn't provide this - } - - return current, nil -} - -// compareAppStates compares current and desired app states and returns changes -func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]string, bool) { - var changes []string - manifestChanged := false - - // Compare manifest hash - only if both states have hash values - // Since EdgeConnect API doesn't support annotations yet, skip manifest hash comparison for now - // This would be implemented when the API supports manifest hash tracking - if current.ManifestHash != "" && desired.ManifestHash != "" && current.ManifestHash != desired.ManifestHash { - changes = append(changes, fmt.Sprintf("Manifest hash changed: %s -> %s", current.ManifestHash, desired.ManifestHash)) - manifestChanged = true - } - - // Compare app type - if current.AppType != desired.AppType { - changes = append(changes, fmt.Sprintf("App type changed: %s -> %s", current.AppType, desired.AppType)) - } - - return changes, manifestChanged -} - -// compareInstanceStates compares current and desired instance states and returns changes -func (p *EdgeConnectPlanner) compareInstanceStates(current, desired *InstanceState) []string { - var changes []string - - if current.FlavorName != desired.FlavorName { - changes = append(changes, fmt.Sprintf("Flavor changed: %s -> %s", current.FlavorName, desired.FlavorName)) - } - - if current.CloudletName != desired.CloudletName { - changes = append(changes, fmt.Sprintf("Cloudlet changed: %s -> %s", current.CloudletName, desired.CloudletName)) - } - - if current.CloudletOrg != desired.CloudletOrg { - changes = append(changes, fmt.Sprintf("Cloudlet org changed: %s -> %s", current.CloudletOrg, desired.CloudletOrg)) - } - - return changes -} - -// calculateManifestHash computes the SHA256 hash of a manifest file -func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string, error) { - if manifestPath == "" { - return "", nil - } - - file, err := os.Open(manifestPath) - if err != nil { - return "", fmt.Errorf("failed to open manifest file: %w", err) - } - defer file.Close() - - hasher := sha256.New() - if _, err := io.Copy(hasher, file); err != nil { - return "", fmt.Errorf("failed to hash manifest file: %w", err) - } - - return fmt.Sprintf("%x", hasher.Sum(nil)), nil -} - -// calculatePlanMetadata computes metadata for the deployment plan -func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeploymentPlan) { - totalActions := 0 - - if plan.AppAction.Type != ActionNone { - totalActions++ - } - - for _, action := range plan.InstanceActions { - if action.Type != ActionNone { - totalActions++ - } - } - - plan.TotalActions = totalActions - - // Estimate duration based on action types and counts - plan.EstimatedDuration = p.estimateDeploymentDuration(plan) -} - -// estimateDeploymentDuration provides a rough estimate of deployment time -func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) time.Duration { - var duration time.Duration - - // App operations - if plan.AppAction.Type == ActionCreate { - duration += 30 * time.Second - } else if plan.AppAction.Type == ActionUpdate { - duration += 15 * time.Second - } - - // Instance operations (can be done in parallel) - instanceDuration := time.Duration(0) - for _, action := range plan.InstanceActions { - if action.Type == ActionCreate { - instanceDuration = max(instanceDuration, 2*time.Minute) - } else if action.Type == ActionUpdate { - instanceDuration = max(instanceDuration, 1*time.Minute) - } - } - - duration += instanceDuration - - // Add buffer time - duration += 30 * time.Second - - return duration -} - -// isResourceNotFoundError checks if an error indicates a resource was not found -func isResourceNotFoundError(err error) bool { - if err == nil { - return false - } - - errStr := strings.ToLower(err.Error()) - return strings.Contains(errStr, "not found") || - strings.Contains(errStr, "does not exist") || - strings.Contains(errStr, "404") -} - -// max returns the larger of two durations -func max(a, b time.Duration) time.Duration { - if a > b { - return a - } - return b -} - -// getInstanceName generates the instance name following the pattern: appName-appVersion-instance -func getInstanceName(appName, appVersion string) string { - return fmt.Sprintf("%s-%s-instance", appName, appVersion) -} \ No newline at end of file diff --git a/sdk/internal/apply/types.go b/sdk/internal/apply/types.go deleted file mode 100644 index 50f9180..0000000 --- a/sdk/internal/apply/types.go +++ /dev/null @@ -1,428 +0,0 @@ -// ABOUTME: Deployment planning types for EdgeConnect apply command with state management -// ABOUTME: Defines structures for deployment plans, actions, and state comparison results -package apply - -import ( - "fmt" - "time" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/config" -) - -// ActionType represents the type of action to be performed -type ActionType string - -const ( - // ActionCreate indicates a resource needs to be created - ActionCreate ActionType = "CREATE" - // ActionUpdate indicates a resource needs to be updated - ActionUpdate ActionType = "UPDATE" - // ActionNone indicates no action is needed - ActionNone ActionType = "NONE" - // ActionDelete indicates a resource needs to be deleted (for rollback scenarios) - ActionDelete ActionType = "DELETE" -) - -// String returns the string representation of ActionType -func (a ActionType) String() string { - return string(a) -} - -// DeploymentPlan represents the complete deployment plan for a configuration -type DeploymentPlan struct { - // ConfigName is the name from metadata - ConfigName string - - // AppAction defines what needs to be done with the application - AppAction AppAction - - // InstanceActions defines what needs to be done with each instance - InstanceActions []InstanceAction - - // Summary provides a human-readable summary of the plan - Summary string - - // TotalActions is the count of all actions that will be performed - TotalActions int - - // EstimatedDuration is the estimated time to complete the deployment - EstimatedDuration time.Duration - - // CreatedAt timestamp when the plan was created - CreatedAt time.Time - - // DryRun indicates if this is a dry-run plan - DryRun bool -} - -// AppAction represents an action to be performed on an application -type AppAction struct { - // Type of action to perform - Type ActionType - - // Current state of the app (nil if doesn't exist) - Current *AppState - - // Desired state of the app - Desired *AppState - - // Changes describes what will change - Changes []string - - // Reason explains why this action is needed - Reason string - - // ManifestHash is the hash of the current manifest file - ManifestHash string - - // ManifestChanged indicates if the manifest content has changed - ManifestChanged bool -} - -// InstanceAction represents an action to be performed on an application instance -type InstanceAction struct { - // Type of action to perform - Type ActionType - - // Target infrastructure where the instance will be deployed - Target config.InfraTemplate - - // Current state of the instance (nil if doesn't exist) - Current *InstanceState - - // Desired state of the instance - Desired *InstanceState - - // Changes describes what will change - Changes []string - - // Reason explains why this action is needed - Reason string - - // InstanceName is the generated name for this instance - InstanceName string - - // Dependencies lists other instances this depends on - Dependencies []string -} - -// AppState represents the current state of an application -type AppState struct { - // Name of the application - Name string - - // Version of the application - Version string - - // Organization that owns the app - Organization string - - // Region where the app is deployed - Region string - - // ManifestHash is the stored hash of the manifest file - ManifestHash string - - // LastUpdated timestamp when the app was last modified - LastUpdated time.Time - - // Exists indicates if the app currently exists - Exists bool - - // AppType indicates whether this is a k8s or docker app - AppType AppType -} - -// InstanceState represents the current state of an application instance -type InstanceState struct { - // Name of the instance - Name string - - // AppName that this instance belongs to - AppName string - - // AppVersion of the associated app - AppVersion string - - // Organization that owns the instance - Organization string - - // Region where the instance is deployed - Region string - - // CloudletOrg that hosts the cloudlet - CloudletOrg string - - // CloudletName where the instance is running - CloudletName string - - // FlavorName used for the instance - FlavorName string - - // State of the instance (e.g., "Ready", "Pending", "Error") - State string - - // PowerState of the instance - PowerState string - - // LastUpdated timestamp when the instance was last modified - LastUpdated time.Time - - // Exists indicates if the instance currently exists - Exists bool -} - -// AppType represents the type of application -type AppType string - -const ( - // AppTypeK8s represents a Kubernetes application - AppTypeK8s AppType = "k8s" - // AppTypeDocker represents a Docker application - AppTypeDocker AppType = "docker" -) - -// String returns the string representation of AppType -func (a AppType) String() string { - return string(a) -} - -// DeploymentSummary provides a high-level overview of the deployment plan -type DeploymentSummary struct { - // TotalActions is the total number of actions to be performed - TotalActions int - - // ActionCounts breaks down actions by type - ActionCounts map[ActionType]int - - // EstimatedDuration for the entire deployment - EstimatedDuration time.Duration - - // ResourceSummary describes the resources involved - ResourceSummary ResourceSummary - - // Warnings about potential issues - Warnings []string -} - -// ResourceSummary provides details about resources in the deployment -type ResourceSummary struct { - // AppsToCreate number of apps that will be created - AppsToCreate int - - // AppsToUpdate number of apps that will be updated - AppsToUpdate int - - // InstancesToCreate number of instances that will be created - InstancesToCreate int - - // InstancesToUpdate number of instances that will be updated - InstancesToUpdate int - - // CloudletsAffected number of unique cloudlets involved - CloudletsAffected int - - // RegionsAffected number of unique regions involved - RegionsAffected int -} - -// PlanResult represents the result of a deployment planning operation -type PlanResult struct { - // Plan is the generated deployment plan - Plan *DeploymentPlan - - // Error if planning failed - Error error - - // Warnings encountered during planning - Warnings []string -} - -// ExecutionResult represents the result of executing a deployment plan -type ExecutionResult struct { - // Plan that was executed - Plan *DeploymentPlan - - // Success indicates if the deployment was successful - Success bool - - // CompletedActions lists actions that were successfully completed - CompletedActions []ActionResult - - // FailedActions lists actions that failed - FailedActions []ActionResult - - // Error that caused the deployment to fail (if any) - Error error - - // Duration taken to execute the plan - Duration time.Duration - - // RollbackPerformed indicates if rollback was executed - RollbackPerformed bool - - // RollbackSuccess indicates if rollback was successful - RollbackSuccess bool -} - -// ActionResult represents the result of executing a single action -type ActionResult struct { - // Type of action that was attempted - Type ActionType - - // Target describes what was being acted upon - Target string - - // Success indicates if the action succeeded - Success bool - - // Error if the action failed - Error error - - // Duration taken to complete the action - Duration time.Duration - - // Details provides additional information about the action - Details string -} - -// IsEmpty returns true if the deployment plan has no actions to perform -func (dp *DeploymentPlan) IsEmpty() bool { - if dp.AppAction.Type != ActionNone { - return false - } - - for _, action := range dp.InstanceActions { - if action.Type != ActionNone { - return false - } - } - - return true -} - -// HasErrors returns true if the plan contains any error conditions -func (dp *DeploymentPlan) HasErrors() bool { - // Check for conflicting actions or invalid states - return false // Implementation would check for various error conditions -} - -// GetTargetCloudlets returns a list of unique cloudlets that will be affected -func (dp *DeploymentPlan) GetTargetCloudlets() []string { - cloudletSet := make(map[string]bool) - var cloudlets []string - - for _, action := range dp.InstanceActions { - if action.Type != ActionNone { - key := fmt.Sprintf("%s:%s", action.Target.CloudletOrg, action.Target.CloudletName) - if !cloudletSet[key] { - cloudletSet[key] = true - cloudlets = append(cloudlets, key) - } - } - } - - return cloudlets -} - -// GetTargetRegions returns a list of unique regions that will be affected -func (dp *DeploymentPlan) GetTargetRegions() []string { - regionSet := make(map[string]bool) - var regions []string - - for _, action := range dp.InstanceActions { - if action.Type != ActionNone && !regionSet[action.Target.Region] { - regionSet[action.Target.Region] = true - regions = append(regions, action.Target.Region) - } - } - - return regions -} - -// GenerateSummary creates a human-readable summary of the deployment plan -func (dp *DeploymentPlan) GenerateSummary() string { - if dp.IsEmpty() { - return "No changes required - configuration matches current state" - } - - summary := fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName) - - // App actions - if dp.AppAction.Type != ActionNone { - summary += fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name) - if len(dp.AppAction.Changes) > 0 { - for _, change := range dp.AppAction.Changes { - summary += fmt.Sprintf(" - %s\n", change) - } - } - } - - // Instance actions - createCount := 0 - updateCount := 0 - for _, action := range dp.InstanceActions { - switch action.Type { - case ActionCreate: - createCount++ - case ActionUpdate: - updateCount++ - } - } - - if createCount > 0 { - summary += fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets())) - } - if updateCount > 0 { - summary += fmt.Sprintf("- UPDATE %d instance(s)\n", updateCount) - } - - summary += fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()) - - return summary -} - -// Validate checks if the deployment plan is valid and safe to execute -func (dp *DeploymentPlan) Validate() error { - if dp.ConfigName == "" { - return fmt.Errorf("deployment plan must have a config name") - } - - // Validate app action - if dp.AppAction.Type != ActionNone && dp.AppAction.Desired == nil { - return fmt.Errorf("app action of type %s must have desired state", dp.AppAction.Type) - } - - // Validate instance actions - for i, action := range dp.InstanceActions { - if action.Type != ActionNone { - if action.Desired == nil { - return fmt.Errorf("instance action %d of type %s must have desired state", i, action.Type) - } - if action.InstanceName == "" { - return fmt.Errorf("instance action %d must have an instance name", i) - } - } - } - - return nil -} - -// Clone creates a deep copy of the deployment plan -func (dp *DeploymentPlan) Clone() *DeploymentPlan { - clone := &DeploymentPlan{ - ConfigName: dp.ConfigName, - Summary: dp.Summary, - TotalActions: dp.TotalActions, - EstimatedDuration: dp.EstimatedDuration, - CreatedAt: dp.CreatedAt, - DryRun: dp.DryRun, - AppAction: dp.AppAction, // Struct copy is sufficient for this use case - } - - // Deep copy instance actions - clone.InstanceActions = make([]InstanceAction, len(dp.InstanceActions)) - copy(clone.InstanceActions, dp.InstanceActions) - - return clone -} \ No newline at end of file diff --git a/sdk/internal/config/example_test.go b/sdk/internal/config/example_test.go deleted file mode 100644 index 67a7b63..0000000 --- a/sdk/internal/config/example_test.go +++ /dev/null @@ -1,130 +0,0 @@ -// ABOUTME: Integration test with the actual EdgeConnectConfig.yaml example file -// ABOUTME: Validates that our parser correctly handles the real example configuration -package config - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParseExampleConfig(t *testing.T) { - parser := NewParser() - - // Parse the actual example file (now that we've created the manifest file) - examplePath := filepath.Join("../../examples/comprehensive/EdgeConnectConfig.yaml") - config, err := parser.ParseFile(examplePath) - - // This should now succeed with full validation - require.NoError(t, err) - require.NotNil(t, config) - - // Validate the parsed structure - assert.Equal(t, "edgeconnect-deployment", config.Kind) - assert.Equal(t, "edge-app-demo", config.Metadata.Name) - - // Check k8s app configuration - require.NotNil(t, config.Spec.K8sApp) - assert.Equal(t, "edge-app-demo", config.Spec.K8sApp.AppName) - assert.Equal(t, "1.0.0", config.Spec.K8sApp.AppVersion) - // Note: ManifestFile path should be resolved to absolute path - assert.Contains(t, config.Spec.K8sApp.ManifestFile, "k8s-deployment.yaml") - - // Check infrastructure template - require.Len(t, config.Spec.InfraTemplate, 1) - infra := config.Spec.InfraTemplate[0] - assert.Equal(t, "edp2", infra.Organization) - assert.Equal(t, "EU", infra.Region) - assert.Equal(t, "TelekomOP", infra.CloudletOrg) - assert.Equal(t, "Munich", infra.CloudletName) - assert.Equal(t, "EU.small", infra.FlavorName) - - // Check network configuration - require.NotNil(t, config.Spec.Network) - require.Len(t, config.Spec.Network.OutboundConnections, 2) - - conn1 := config.Spec.Network.OutboundConnections[0] - assert.Equal(t, "tcp", conn1.Protocol) - assert.Equal(t, 80, conn1.PortRangeMin) - assert.Equal(t, 80, conn1.PortRangeMax) - assert.Equal(t, "0.0.0.0/0", conn1.RemoteCIDR) - - conn2 := config.Spec.Network.OutboundConnections[1] - assert.Equal(t, "tcp", conn2.Protocol) - assert.Equal(t, 443, conn2.PortRangeMin) - assert.Equal(t, 443, conn2.PortRangeMax) - assert.Equal(t, "0.0.0.0/0", conn2.RemoteCIDR) - - // Test utility methods - assert.Equal(t, "edge-app-demo", config.Spec.GetAppName()) - assert.Equal(t, "1.0.0", config.Spec.GetAppVersion()) - assert.Contains(t, config.Spec.GetManifestFile(), "k8s-deployment.yaml") - assert.True(t, config.Spec.IsK8sApp()) - assert.False(t, config.Spec.IsDockerApp()) - - // Test instance name generation - instanceName := GetInstanceName(config.Spec.GetAppName(), config.Spec.GetAppVersion()) - assert.Equal(t, "edge-app-demo-1.0.0-instance", instanceName) -} - -func TestValidateExampleStructure(t *testing.T) { - parser := &ConfigParser{} - - // Create a config that matches the example but with valid paths - config := &EdgeConnectConfig{ - Kind: "edgeconnect-deployment", - Metadata: Metadata{ - Name: "edge-app-demo", - }, - Spec: Spec{ - DockerApp: &DockerApp{ // Use DockerApp to avoid manifest file validation - AppName: "edge-app-demo", - AppVersion: "1.0.0", - Image: "nginx:latest", - }, - InfraTemplate: []InfraTemplate{ - { - Organization: "edp2", - Region: "EU", - CloudletOrg: "TelekomOP", - CloudletName: "Munich", - FlavorName: "EU.small", - }, - }, - Network: &NetworkConfig{ - OutboundConnections: []OutboundConnection{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - { - Protocol: "tcp", - PortRangeMin: 443, - PortRangeMax: 443, - RemoteCIDR: "0.0.0.0/0", - }, - }, - }, - }, - } - - // This should validate successfully - err := parser.Validate(config) - assert.NoError(t, err) - - // Test comprehensive validation - err = parser.ComprehensiveValidate(config) - assert.NoError(t, err) - - // Test infrastructure uniqueness validation - err = parser.ValidateInfrastructureUniqueness(config) - assert.NoError(t, err) - - // Test port range validation - err = parser.ValidatePortRanges(config) - assert.NoError(t, err) -} \ No newline at end of file diff --git a/sdk/internal/config/parser.go b/sdk/internal/config/parser.go deleted file mode 100644 index 238c22e..0000000 --- a/sdk/internal/config/parser.go +++ /dev/null @@ -1,248 +0,0 @@ -// ABOUTME: YAML configuration parser for EdgeConnect apply command with comprehensive validation -// ABOUTME: Handles parsing and validation of EdgeConnectConfig files with detailed error messages -package config - -import ( - "fmt" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -// Parser defines the interface for configuration parsing -type Parser interface { - ParseFile(filename string) (*EdgeConnectConfig, error) - ParseBytes(data []byte) (*EdgeConnectConfig, error) - Validate(config *EdgeConnectConfig) error -} - -// ConfigParser implements the Parser interface -type ConfigParser struct{} - -// NewParser creates a new configuration parser -func NewParser() Parser { - return &ConfigParser{} -} - -// ParseFile parses an EdgeConnectConfig from a YAML file -func (p *ConfigParser) ParseFile(filename string) (*EdgeConnectConfig, error) { - if filename == "" { - return nil, fmt.Errorf("filename cannot be empty") - } - - // Check if file exists - if _, err := os.Stat(filename); os.IsNotExist(err) { - return nil, fmt.Errorf("configuration file does not exist: %s", filename) - } - - // Read file contents - data, err := os.ReadFile(filename) - if err != nil { - return nil, fmt.Errorf("failed to read configuration file %s: %w", filename, err) - } - - // Parse YAML without validation first - config, err := p.parseYAMLOnly(data) - if err != nil { - return nil, fmt.Errorf("failed to parse configuration file %s: %w", filename, err) - } - - // Resolve relative paths relative to config file directory - configDir := filepath.Dir(filename) - if err := p.resolveRelativePaths(config, configDir); err != nil { - return nil, fmt.Errorf("failed to resolve paths in %s: %w", filename, err) - } - - // Now validate with resolved paths - if err := p.Validate(config); err != nil { - return nil, fmt.Errorf("configuration validation failed in %s: %w", filename, err) - } - - return config, nil -} - -// parseYAMLOnly parses YAML without validation -func (p *ConfigParser) parseYAMLOnly(data []byte) (*EdgeConnectConfig, error) { - if len(data) == 0 { - return nil, fmt.Errorf("configuration data cannot be empty") - } - - var config EdgeConnectConfig - - // Parse YAML with strict mode - decoder := yaml.NewDecoder(nil) - decoder.KnownFields(true) // Fail on unknown fields - - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("YAML parsing failed: %w", err) - } - - return &config, nil -} - -// ParseBytes parses an EdgeConnectConfig from YAML bytes -func (p *ConfigParser) ParseBytes(data []byte) (*EdgeConnectConfig, error) { - // Parse YAML only - config, err := p.parseYAMLOnly(data) - if err != nil { - return nil, err - } - - // Validate the parsed configuration - if err := p.Validate(config); err != nil { - return nil, fmt.Errorf("configuration validation failed: %w", err) - } - - return config, nil -} - -// Validate performs comprehensive validation of the configuration -func (p *ConfigParser) Validate(config *EdgeConnectConfig) error { - if config == nil { - return fmt.Errorf("configuration cannot be nil") - } - - return config.Validate() -} - -// resolveRelativePaths converts relative paths to absolute paths based on config directory -func (p *ConfigParser) resolveRelativePaths(config *EdgeConnectConfig, configDir string) error { - if config.Spec.K8sApp != nil { - resolved := config.Spec.K8sApp.GetManifestPath(configDir) - config.Spec.K8sApp.ManifestFile = resolved - } - - if config.Spec.DockerApp != nil && config.Spec.DockerApp.ManifestFile != "" { - resolved := config.Spec.DockerApp.GetManifestPath(configDir) - config.Spec.DockerApp.ManifestFile = resolved - } - - return nil -} - -// ValidateManifestFiles performs additional validation on manifest files -func (p *ConfigParser) ValidateManifestFiles(config *EdgeConnectConfig) error { - var manifestFile string - - if config.Spec.K8sApp != nil { - manifestFile = config.Spec.K8sApp.ManifestFile - } else if config.Spec.DockerApp != nil { - manifestFile = config.Spec.DockerApp.ManifestFile - } - - if manifestFile != "" { - if err := p.validateManifestFile(manifestFile); err != nil { - return fmt.Errorf("manifest file validation failed: %w", err) - } - } - - return nil -} - -// validateManifestFile checks if the manifest file is valid and readable -func (p *ConfigParser) validateManifestFile(filename string) error { - info, err := os.Stat(filename) - if err != nil { - return fmt.Errorf("cannot access manifest file %s: %w", filename, err) - } - - if info.IsDir() { - return fmt.Errorf("manifest file cannot be a directory: %s", filename) - } - - if info.Size() == 0 { - return fmt.Errorf("manifest file cannot be empty: %s", filename) - } - - // Try to read the file to ensure it's accessible - if _, err := os.ReadFile(filename); err != nil { - return fmt.Errorf("cannot read manifest file %s: %w", filename, err) - } - - return nil -} - -// GetInstanceName generates the instance name following the pattern: appName-appVersion-instance -func GetInstanceName(appName, appVersion string) string { - return fmt.Sprintf("%s-%s-instance", appName, appVersion) -} - -// ValidateInfrastructureUniqueness ensures no duplicate infrastructure targets -func (p *ConfigParser) ValidateInfrastructureUniqueness(config *EdgeConnectConfig) error { - seen := make(map[string]bool) - - for i, infra := range config.Spec.InfraTemplate { - key := fmt.Sprintf("%s:%s:%s:%s", - infra.Organization, - infra.Region, - infra.CloudletOrg, - infra.CloudletName) - - if seen[key] { - return fmt.Errorf("duplicate infrastructure target at index %d: org=%s, region=%s, cloudletOrg=%s, cloudletName=%s", - i, infra.Organization, infra.Region, infra.CloudletOrg, infra.CloudletName) - } - - seen[key] = true - } - - return nil -} - -// ValidatePortRanges ensures port ranges don't overlap in network configuration -func (p *ConfigParser) ValidatePortRanges(config *EdgeConnectConfig) error { - if config.Spec.Network == nil { - return nil - } - - connections := config.Spec.Network.OutboundConnections - for i := 0; i < len(connections); i++ { - for j := i + 1; j < len(connections); j++ { - conn1 := connections[i] - conn2 := connections[j] - - // Only check same protocol and CIDR - if conn1.Protocol == conn2.Protocol && conn1.RemoteCIDR == conn2.RemoteCIDR { - if portRangesOverlap(conn1.PortRangeMin, conn1.PortRangeMax, conn2.PortRangeMin, conn2.PortRangeMax) { - return fmt.Errorf("overlapping port ranges for protocol %s and CIDR %s: [%d-%d] overlaps with [%d-%d]", - conn1.Protocol, conn1.RemoteCIDR, - conn1.PortRangeMin, conn1.PortRangeMax, - conn2.PortRangeMin, conn2.PortRangeMax) - } - } - } - } - - return nil -} - -// portRangesOverlap checks if two port ranges overlap -func portRangesOverlap(min1, max1, min2, max2 int) bool { - return max1 >= min2 && max2 >= min1 -} - -// ComprehensiveValidate performs all validation checks including extended ones -func (p *ConfigParser) ComprehensiveValidate(config *EdgeConnectConfig) error { - // Basic validation - if err := p.Validate(config); err != nil { - return err - } - - // Manifest file validation - if err := p.ValidateManifestFiles(config); err != nil { - return err - } - - // Infrastructure uniqueness validation - if err := p.ValidateInfrastructureUniqueness(config); err != nil { - return err - } - - // Port range validation - if err := p.ValidatePortRanges(config); err != nil { - return err - } - - return nil -} \ No newline at end of file diff --git a/sdk/internal/config/parser_test.go b/sdk/internal/config/parser_test.go deleted file mode 100644 index 01bb222..0000000 --- a/sdk/internal/config/parser_test.go +++ /dev/null @@ -1,789 +0,0 @@ -// ABOUTME: Comprehensive tests for EdgeConnect configuration parser with validation scenarios -// ABOUTME: Tests all validation rules, error conditions, and successful parsing cases -package config - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewParser(t *testing.T) { - parser := NewParser() - assert.NotNil(t, parser) - assert.IsType(t, &ConfigParser{}, parser) -} - -func TestConfigParser_ParseBytes(t *testing.T) { - parser := NewParser() - - tests := []struct { - name string - yaml string - wantErr bool - errMsg string - }{ - { - name: "valid k8s config", - yaml: ` -kind: edgeconnect-deployment -metadata: - name: "test-app" -spec: - k8sApp: - appName: "test-app" - appVersion: "1.0.0" - manifestFile: "./test-manifest.yaml" - infraTemplate: - - organization: "testorg" - region: "US" - cloudletOrg: "TestOP" - cloudletName: "TestCloudlet" - flavorName: "small" -`, - wantErr: true, // Will fail because manifest file doesn't exist - errMsg: "manifestFile does not exist", - }, - { - name: "valid docker config", - yaml: ` -kind: edgeconnect-deployment -metadata: - name: "test-app" -spec: - dockerApp: - appName: "test-app" - appVersion: "1.0.0" - image: "nginx:latest" - infraTemplate: - - organization: "testorg" - region: "US" - cloudletOrg: "TestOP" - cloudletName: "TestCloudlet" - flavorName: "small" -`, - wantErr: false, - }, - { - name: "missing kind", - yaml: ` -metadata: - name: "test-app" -spec: - k8sApp: - appName: "test-app" - appVersion: "1.0.0" - manifestFile: "./test-manifest.yaml" - infraTemplate: - - organization: "testorg" - region: "US" - cloudletOrg: "TestOP" - cloudletName: "TestCloudlet" - flavorName: "small" -`, - wantErr: true, - errMsg: "kind is required", - }, - { - name: "invalid kind", - yaml: ` -kind: invalid-kind -metadata: - name: "test-app" -spec: - dockerApp: - appName: "test-app" - appVersion: "1.0.0" - image: "nginx:latest" - infraTemplate: - - organization: "testorg" - region: "US" - cloudletOrg: "TestOP" - cloudletName: "TestCloudlet" - flavorName: "small" -`, - wantErr: true, - errMsg: "unsupported kind: invalid-kind", - }, - { - name: "missing app definition", - yaml: ` -kind: edgeconnect-deployment -metadata: - name: "test-app" -spec: - infraTemplate: - - organization: "testorg" - region: "US" - cloudletOrg: "TestOP" - cloudletName: "TestCloudlet" - flavorName: "small" -`, - wantErr: true, - errMsg: "spec must define either k8sApp or dockerApp", - }, - { - name: "both k8s and docker apps", - yaml: ` -kind: edgeconnect-deployment -metadata: - name: "test-app" -spec: - k8sApp: - appName: "test-app" - appVersion: "1.0.0" - manifestFile: "./test-manifest.yaml" - dockerApp: - appName: "test-app" - appVersion: "1.0.0" - image: "nginx:latest" - infraTemplate: - - organization: "testorg" - region: "US" - cloudletOrg: "TestOP" - cloudletName: "TestCloudlet" - flavorName: "small" -`, - wantErr: true, - errMsg: "spec cannot define both k8sApp and dockerApp", - }, - { - name: "empty infrastructure template", - yaml: ` -kind: edgeconnect-deployment -metadata: - name: "test-app" -spec: - dockerApp: - appName: "test-app" - appVersion: "1.0.0" - image: "nginx:latest" - infraTemplate: [] -`, - wantErr: true, - errMsg: "infraTemplate is required and must contain at least one target", - }, - { - name: "with network config", - yaml: ` -kind: edgeconnect-deployment -metadata: - name: "test-app" -spec: - dockerApp: - appName: "test-app" - appVersion: "1.0.0" - image: "nginx:latest" - infraTemplate: - - organization: "testorg" - region: "US" - cloudletOrg: "TestOP" - cloudletName: "TestCloudlet" - flavorName: "small" - network: - outboundConnections: - - protocol: "tcp" - portRangeMin: 80 - portRangeMax: 80 - remoteCIDR: "0.0.0.0/0" -`, - wantErr: false, - }, - { - name: "empty data", - yaml: "", - wantErr: true, - errMsg: "configuration data cannot be empty", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config, err := parser.ParseBytes([]byte(tt.yaml)) - - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - assert.Nil(t, config) - } else { - assert.NoError(t, err) - assert.NotNil(t, config) - } - }) - } -} - -func TestConfigParser_ParseFile(t *testing.T) { - parser := NewParser() - - // Create temporary directory for test files - tempDir := t.TempDir() - - // Create a valid config file - validConfig := ` -kind: edgeconnect-deployment -metadata: - name: "test-app" -spec: - dockerApp: - appName: "test-app" - appVersion: "1.0.0" - image: "nginx:latest" - infraTemplate: - - organization: "testorg" - region: "US" - cloudletOrg: "TestOP" - cloudletName: "TestCloudlet" - flavorName: "small" -` - - validFile := filepath.Join(tempDir, "valid.yaml") - err := os.WriteFile(validFile, []byte(validConfig), 0644) - require.NoError(t, err) - - // Test valid file parsing - config, err := parser.ParseFile(validFile) - assert.NoError(t, err) - assert.NotNil(t, config) - assert.Equal(t, "edgeconnect-deployment", config.Kind) - assert.Equal(t, "test-app", config.Metadata.Name) - - // Test non-existent file - nonExistentFile := filepath.Join(tempDir, "nonexistent.yaml") - config, err = parser.ParseFile(nonExistentFile) - assert.Error(t, err) - assert.Contains(t, err.Error(), "does not exist") - assert.Nil(t, config) - - // Test empty filename - config, err = parser.ParseFile("") - assert.Error(t, err) - assert.Contains(t, err.Error(), "filename cannot be empty") - assert.Nil(t, config) - - // Test invalid YAML - invalidFile := filepath.Join(tempDir, "invalid.yaml") - err = os.WriteFile(invalidFile, []byte("invalid: yaml: content: ["), 0644) - require.NoError(t, err) - - config, err = parser.ParseFile(invalidFile) - assert.Error(t, err) - assert.Contains(t, err.Error(), "YAML parsing failed") - assert.Nil(t, config) -} - -func TestConfigParser_RelativePathResolution(t *testing.T) { - parser := NewParser() - tempDir := t.TempDir() - - // Create a manifest file - manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" - manifestFile := filepath.Join(tempDir, "manifest.yaml") - err := os.WriteFile(manifestFile, []byte(manifestContent), 0644) - require.NoError(t, err) - - // Create config with relative path - configContent := ` -kind: edgeconnect-deployment -metadata: - name: "test-app" -spec: - k8sApp: - appName: "test-app" - appVersion: "1.0.0" - manifestFile: "./manifest.yaml" - infraTemplate: - - organization: "testorg" - region: "US" - cloudletOrg: "TestOP" - cloudletName: "TestCloudlet" - flavorName: "small" -` - - configFile := filepath.Join(tempDir, "config.yaml") - err = os.WriteFile(configFile, []byte(configContent), 0644) - require.NoError(t, err) - - config, err := parser.ParseFile(configFile) - assert.NoError(t, err) - assert.NotNil(t, config) - - // Check that relative path was resolved to absolute - expectedPath := filepath.Join(tempDir, "manifest.yaml") - assert.Equal(t, expectedPath, config.Spec.K8sApp.ManifestFile) -} - -func TestEdgeConnectConfig_Validate(t *testing.T) { - tests := []struct { - name string - config EdgeConnectConfig - wantErr bool - errMsg string - }{ - { - name: "valid config", - config: EdgeConnectConfig{ - Kind: "edgeconnect-deployment", - Metadata: Metadata{ - Name: "test-app", - }, - Spec: Spec{ - DockerApp: &DockerApp{ - AppName: "test-app", - AppVersion: "1.0.0", - Image: "nginx:latest", - }, - InfraTemplate: []InfraTemplate{ - { - Organization: "testorg", - Region: "US", - CloudletOrg: "TestOP", - CloudletName: "TestCloudlet", - FlavorName: "small", - }, - }, - }, - }, - wantErr: false, - }, - { - name: "missing kind", - config: EdgeConnectConfig{ - Metadata: Metadata{Name: "test"}, - }, - wantErr: true, - errMsg: "kind is required", - }, - { - name: "invalid kind", - config: EdgeConnectConfig{ - Kind: "invalid", - Metadata: Metadata{Name: "test"}, - }, - wantErr: true, - errMsg: "unsupported kind", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.config.Validate() - - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestMetadata_Validate(t *testing.T) { - tests := []struct { - name string - metadata Metadata - wantErr bool - errMsg string - }{ - { - name: "valid metadata", - metadata: Metadata{Name: "test-app"}, - wantErr: false, - }, - { - name: "empty name", - metadata: Metadata{Name: ""}, - wantErr: true, - errMsg: "metadata.name is required", - }, - { - name: "name with leading whitespace", - metadata: Metadata{Name: " test-app"}, - wantErr: true, - errMsg: "cannot have leading/trailing whitespace", - }, - { - name: "name with trailing whitespace", - metadata: Metadata{Name: "test-app "}, - wantErr: true, - errMsg: "cannot have leading/trailing whitespace", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.metadata.Validate() - - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestOutboundConnection_Validate(t *testing.T) { - tests := []struct { - name string - connection OutboundConnection - wantErr bool - errMsg string - }{ - { - name: "valid connection", - connection: OutboundConnection{ - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - wantErr: false, - }, - { - name: "missing protocol", - connection: OutboundConnection{ - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - wantErr: true, - errMsg: "protocol is required", - }, - { - name: "invalid protocol", - connection: OutboundConnection{ - Protocol: "invalid", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - wantErr: true, - errMsg: "protocol must be one of: tcp, udp, icmp", - }, - { - name: "invalid port range min", - connection: OutboundConnection{ - Protocol: "tcp", - PortRangeMin: 0, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - wantErr: true, - errMsg: "portRangeMin must be between 1 and 65535", - }, - { - name: "invalid port range max", - connection: OutboundConnection{ - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 65536, - RemoteCIDR: "0.0.0.0/0", - }, - wantErr: true, - errMsg: "portRangeMax must be between 1 and 65535", - }, - { - name: "min greater than max", - connection: OutboundConnection{ - Protocol: "tcp", - PortRangeMin: 443, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - wantErr: true, - errMsg: "portRangeMin (443) cannot be greater than portRangeMax (80)", - }, - { - name: "missing remote CIDR", - connection: OutboundConnection{ - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - }, - wantErr: true, - errMsg: "remoteCIDR is required", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.connection.Validate() - - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestConfigParser_ValidateInfrastructureUniqueness(t *testing.T) { - parser := &ConfigParser{} - - tests := []struct { - name string - config *EdgeConnectConfig - wantErr bool - errMsg string - }{ - { - name: "unique infrastructure", - config: &EdgeConnectConfig{ - Spec: Spec{ - InfraTemplate: []InfraTemplate{ - { - Organization: "org1", - Region: "US", - CloudletOrg: "cloudlet1", - CloudletName: "name1", - }, - { - Organization: "org1", - Region: "EU", - CloudletOrg: "cloudlet1", - CloudletName: "name1", - }, - }, - }, - }, - wantErr: false, - }, - { - name: "duplicate infrastructure", - config: &EdgeConnectConfig{ - Spec: Spec{ - InfraTemplate: []InfraTemplate{ - { - Organization: "org1", - Region: "US", - CloudletOrg: "cloudlet1", - CloudletName: "name1", - }, - { - Organization: "org1", - Region: "US", - CloudletOrg: "cloudlet1", - CloudletName: "name1", - }, - }, - }, - }, - wantErr: true, - errMsg: "duplicate infrastructure target", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := parser.ValidateInfrastructureUniqueness(tt.config) - - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestConfigParser_ValidatePortRanges(t *testing.T) { - parser := &ConfigParser{} - - tests := []struct { - name string - config *EdgeConnectConfig - wantErr bool - errMsg string - }{ - { - name: "no network config", - config: &EdgeConnectConfig{ - Spec: Spec{ - Network: nil, - }, - }, - wantErr: false, - }, - { - name: "non-overlapping ports", - config: &EdgeConnectConfig{ - Spec: Spec{ - Network: &NetworkConfig{ - OutboundConnections: []OutboundConnection{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - { - Protocol: "tcp", - PortRangeMin: 443, - PortRangeMax: 443, - RemoteCIDR: "0.0.0.0/0", - }, - }, - }, - }, - }, - wantErr: false, - }, - { - name: "overlapping ports same protocol and CIDR", - config: &EdgeConnectConfig{ - Spec: Spec{ - Network: &NetworkConfig{ - OutboundConnections: []OutboundConnection{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 90, - RemoteCIDR: "0.0.0.0/0", - }, - { - Protocol: "tcp", - PortRangeMin: 85, - PortRangeMax: 95, - RemoteCIDR: "0.0.0.0/0", - }, - }, - }, - }, - }, - wantErr: true, - errMsg: "overlapping port ranges", - }, - { - name: "overlapping ports different protocol", - config: &EdgeConnectConfig{ - Spec: Spec{ - Network: &NetworkConfig{ - OutboundConnections: []OutboundConnection{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 90, - RemoteCIDR: "0.0.0.0/0", - }, - { - Protocol: "udp", - PortRangeMin: 85, - PortRangeMax: 95, - RemoteCIDR: "0.0.0.0/0", - }, - }, - }, - }, - }, - wantErr: false, // Different protocols can overlap - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := parser.ValidatePortRanges(tt.config) - - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestGetInstanceName(t *testing.T) { - tests := []struct { - appName string - appVersion string - expected string - }{ - {"myapp", "1.0.0", "myapp-1.0.0-instance"}, - {"test-app", "v2.1", "test-app-v2.1-instance"}, - {"app", "latest", "app-latest-instance"}, - } - - for _, tt := range tests { - t.Run(tt.appName+"-"+tt.appVersion, func(t *testing.T) { - result := GetInstanceName(tt.appName, tt.appVersion) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestSpec_GetMethods(t *testing.T) { - k8sSpec := &Spec{ - K8sApp: &K8sApp{ - AppName: "k8s-app", - AppVersion: "1.0.0", - ManifestFile: "k8s.yaml", - }, - } - - dockerSpec := &Spec{ - DockerApp: &DockerApp{ - AppName: "docker-app", - AppVersion: "2.0.0", - ManifestFile: "docker.yaml", - }, - } - - assert.Equal(t, "k8s-app", k8sSpec.GetAppName()) - assert.Equal(t, "1.0.0", k8sSpec.GetAppVersion()) - assert.Equal(t, "k8s.yaml", k8sSpec.GetManifestFile()) - assert.True(t, k8sSpec.IsK8sApp()) - assert.False(t, k8sSpec.IsDockerApp()) - - assert.Equal(t, "docker-app", dockerSpec.GetAppName()) - assert.Equal(t, "2.0.0", dockerSpec.GetAppVersion()) - assert.Equal(t, "docker.yaml", dockerSpec.GetManifestFile()) - assert.False(t, dockerSpec.IsK8sApp()) - assert.True(t, dockerSpec.IsDockerApp()) -} - -func TestPortRangesOverlap(t *testing.T) { - tests := []struct { - name string - min1 int - max1 int - min2 int - max2 int - expected bool - }{ - {"no overlap", 10, 20, 30, 40, false}, - {"overlap", 10, 20, 15, 25, true}, - {"adjacent", 10, 20, 21, 30, false}, - {"touching", 10, 20, 20, 30, true}, - {"contained", 10, 30, 15, 25, true}, - {"same range", 10, 20, 10, 20, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := portRangesOverlap(tt.min1, tt.max1, tt.min2, tt.max2) - assert.Equal(t, tt.expected, result) - }) - } -} \ No newline at end of file diff --git a/sdk/internal/config/types.go b/sdk/internal/config/types.go deleted file mode 100644 index 653fb1a..0000000 --- a/sdk/internal/config/types.go +++ /dev/null @@ -1,365 +0,0 @@ -// ABOUTME: Configuration types for EdgeConnect apply command YAML parsing -// ABOUTME: Defines structs that match EdgeConnectConfig.yaml schema exactly -package config - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -// EdgeConnectConfig represents the top-level configuration structure -type EdgeConnectConfig struct { - Kind string `yaml:"kind"` - Metadata Metadata `yaml:"metadata"` - Spec Spec `yaml:"spec"` -} - -// Metadata contains configuration metadata -type Metadata struct { - Name string `yaml:"name"` -} - -// Spec defines the application and infrastructure specification -type Spec struct { - K8sApp *K8sApp `yaml:"k8sApp,omitempty"` - DockerApp *DockerApp `yaml:"dockerApp,omitempty"` - InfraTemplate []InfraTemplate `yaml:"infraTemplate"` - Network *NetworkConfig `yaml:"network,omitempty"` -} - -// K8sApp defines Kubernetes application configuration -type K8sApp struct { - AppName string `yaml:"appName"` - AppVersion string `yaml:"appVersion"` - ManifestFile string `yaml:"manifestFile"` -} - -// DockerApp defines Docker application configuration -type DockerApp struct { - AppName string `yaml:"appName"` - AppVersion string `yaml:"appVersion"` - ManifestFile string `yaml:"manifestFile"` - Image string `yaml:"image"` -} - -// InfraTemplate defines infrastructure deployment targets -type InfraTemplate struct { - Organization string `yaml:"organization"` - Region string `yaml:"region"` - CloudletOrg string `yaml:"cloudletOrg"` - CloudletName string `yaml:"cloudletName"` - FlavorName string `yaml:"flavorName"` -} - -// NetworkConfig defines network configuration -type NetworkConfig struct { - OutboundConnections []OutboundConnection `yaml:"outboundConnections"` -} - -// OutboundConnection defines an outbound network connection -type OutboundConnection struct { - Protocol string `yaml:"protocol"` - PortRangeMin int `yaml:"portRangeMin"` - PortRangeMax int `yaml:"portRangeMax"` - RemoteCIDR string `yaml:"remoteCIDR"` -} - -// Validate performs comprehensive validation of the configuration -func (c *EdgeConnectConfig) Validate() error { - if c.Kind == "" { - return fmt.Errorf("kind is required") - } - - if c.Kind != "edgeconnect-deployment" { - return fmt.Errorf("unsupported kind: %s, expected 'edgeconnect-deployment'", c.Kind) - } - - if err := c.Metadata.Validate(); err != nil { - return fmt.Errorf("metadata validation failed: %w", err) - } - - if err := c.Spec.Validate(); err != nil { - return fmt.Errorf("spec validation failed: %w", err) - } - - return nil -} - -// Validate validates metadata fields -func (m *Metadata) Validate() error { - if m.Name == "" { - return fmt.Errorf("metadata.name is required") - } - - if strings.TrimSpace(m.Name) != m.Name { - return fmt.Errorf("metadata.name cannot have leading/trailing whitespace") - } - - return nil -} - -// Validate validates spec configuration -func (s *Spec) Validate() error { - // Must have either k8sApp or dockerApp, but not both - if s.K8sApp == nil && s.DockerApp == nil { - return fmt.Errorf("spec must define either k8sApp or dockerApp") - } - - if s.K8sApp != nil && s.DockerApp != nil { - return fmt.Errorf("spec cannot define both k8sApp and dockerApp") - } - - // Validate app configuration - if s.K8sApp != nil { - if err := s.K8sApp.Validate(); err != nil { - return fmt.Errorf("k8sApp validation failed: %w", err) - } - } - - if s.DockerApp != nil { - if err := s.DockerApp.Validate(); err != nil { - return fmt.Errorf("dockerApp validation failed: %w", err) - } - } - - // Infrastructure template is required - if len(s.InfraTemplate) == 0 { - return fmt.Errorf("infraTemplate is required and must contain at least one target") - } - - // Validate each infrastructure template - for i, infra := range s.InfraTemplate { - if err := infra.Validate(); err != nil { - return fmt.Errorf("infraTemplate[%d] validation failed: %w", i, err) - } - } - - // Validate network configuration if present - if s.Network != nil { - if err := s.Network.Validate(); err != nil { - return fmt.Errorf("network validation failed: %w", err) - } - } - - return nil -} - -// Validate validates k8s app configuration -func (k *K8sApp) Validate() error { - if k.AppName == "" { - return fmt.Errorf("appName is required") - } - - if k.AppVersion == "" { - return fmt.Errorf("appVersion is required") - } - - if k.ManifestFile == "" { - return fmt.Errorf("manifestFile is required") - } - - // Check if manifest file exists - if _, err := os.Stat(k.ManifestFile); os.IsNotExist(err) { - return fmt.Errorf("manifestFile does not exist: %s", k.ManifestFile) - } - - // Validate app name format - if strings.TrimSpace(k.AppName) != k.AppName { - return fmt.Errorf("appName cannot have leading/trailing whitespace") - } - - // Validate version format - if strings.TrimSpace(k.AppVersion) != k.AppVersion { - return fmt.Errorf("appVersion cannot have leading/trailing whitespace") - } - - return nil -} - -// Validate validates docker app configuration -func (d *DockerApp) Validate() error { - if d.AppName == "" { - return fmt.Errorf("appName is required") - } - - if d.AppVersion == "" { - return fmt.Errorf("appVersion is required") - } - - if d.Image == "" { - return fmt.Errorf("image is required") - } - - // Validate app name format - if strings.TrimSpace(d.AppName) != d.AppName { - return fmt.Errorf("appName cannot have leading/trailing whitespace") - } - - // Validate version format - if strings.TrimSpace(d.AppVersion) != d.AppVersion { - return fmt.Errorf("appVersion cannot have leading/trailing whitespace") - } - - // Check if manifest file exists if specified - if d.ManifestFile != "" { - if _, err := os.Stat(d.ManifestFile); os.IsNotExist(err) { - return fmt.Errorf("manifestFile does not exist: %s", d.ManifestFile) - } - } - - return nil -} - -// Validate validates infrastructure template configuration -func (i *InfraTemplate) Validate() error { - if i.Organization == "" { - return fmt.Errorf("organization is required") - } - - if i.Region == "" { - return fmt.Errorf("region is required") - } - - if i.CloudletOrg == "" { - return fmt.Errorf("cloudletOrg is required") - } - - if i.CloudletName == "" { - return fmt.Errorf("cloudletName is required") - } - - if i.FlavorName == "" { - return fmt.Errorf("flavorName is required") - } - - // Validate no leading/trailing whitespace - fields := map[string]string{ - "organization": i.Organization, - "region": i.Region, - "cloudletOrg": i.CloudletOrg, - "cloudletName": i.CloudletName, - "flavorName": i.FlavorName, - } - - for field, value := range fields { - if strings.TrimSpace(value) != value { - return fmt.Errorf("%s cannot have leading/trailing whitespace", field) - } - } - - return nil -} - -// Validate validates network configuration -func (n *NetworkConfig) Validate() error { - if len(n.OutboundConnections) == 0 { - return fmt.Errorf("outboundConnections is required when network is specified") - } - - for i, conn := range n.OutboundConnections { - if err := conn.Validate(); err != nil { - return fmt.Errorf("outboundConnections[%d] validation failed: %w", i, err) - } - } - - return nil -} - -// Validate validates outbound connection configuration -func (o *OutboundConnection) Validate() error { - if o.Protocol == "" { - return fmt.Errorf("protocol is required") - } - - validProtocols := map[string]bool{ - "tcp": true, - "udp": true, - "icmp": true, - } - - if !validProtocols[strings.ToLower(o.Protocol)] { - return fmt.Errorf("protocol must be one of: tcp, udp, icmp") - } - - if o.PortRangeMin <= 0 || o.PortRangeMin > 65535 { - return fmt.Errorf("portRangeMin must be between 1 and 65535") - } - - if o.PortRangeMax <= 0 || o.PortRangeMax > 65535 { - return fmt.Errorf("portRangeMax must be between 1 and 65535") - } - - if o.PortRangeMin > o.PortRangeMax { - return fmt.Errorf("portRangeMin (%d) cannot be greater than portRangeMax (%d)", o.PortRangeMin, o.PortRangeMax) - } - - if o.RemoteCIDR == "" { - return fmt.Errorf("remoteCIDR is required") - } - - return nil -} - -// GetManifestPath returns the absolute path to the manifest file -func (k *K8sApp) GetManifestPath(configDir string) string { - if filepath.IsAbs(k.ManifestFile) { - return k.ManifestFile - } - return filepath.Join(configDir, k.ManifestFile) -} - -// GetManifestPath returns the absolute path to the manifest file -func (d *DockerApp) GetManifestPath(configDir string) string { - if d.ManifestFile == "" { - return "" - } - if filepath.IsAbs(d.ManifestFile) { - return d.ManifestFile - } - return filepath.Join(configDir, d.ManifestFile) -} - -// GetAppName returns the application name from the active app type -func (s *Spec) GetAppName() string { - if s.K8sApp != nil { - return s.K8sApp.AppName - } - if s.DockerApp != nil { - return s.DockerApp.AppName - } - return "" -} - -// GetAppVersion returns the application version from the active app type -func (s *Spec) GetAppVersion() string { - if s.K8sApp != nil { - return s.K8sApp.AppVersion - } - if s.DockerApp != nil { - return s.DockerApp.AppVersion - } - return "" -} - -// GetManifestFile returns the manifest file path from the active app type -func (s *Spec) GetManifestFile() string { - if s.K8sApp != nil { - return s.K8sApp.ManifestFile - } - if s.DockerApp != nil { - return s.DockerApp.ManifestFile - } - return "" -} - -// IsK8sApp returns true if this is a Kubernetes application -func (s *Spec) IsK8sApp() bool { - return s.K8sApp != nil -} - -// IsDockerApp returns true if this is a Docker application -func (s *Spec) IsDockerApp() bool { - return s.DockerApp != nil -} \ No newline at end of file