From 7bfdeba49fd40516d405243b0d352ed9bb817e26 Mon Sep 17 00:00:00 2001 From: Waldemar Date: Wed, 1 Oct 2025 10:49:15 +0200 Subject: [PATCH] feat(sdk, cli): Implemented update endpoints. Added recreate deployment strategy to cli. Fixed tests. --- cmd/apply.go | 7 +- internal/apply/manager.go | 374 ++++---------------- internal/apply/manager_test.go | 161 ++------- internal/apply/planner.go | 63 ++++ internal/apply/planner_test.go | 124 ++++++- internal/apply/strategy.go | 106 ++++++ internal/apply/strategy_recreate.go | 505 ++++++++++++++++++++++++++++ internal/apply/types.go | 23 ++ internal/config/config_test.go | 46 +++ internal/config/example_test.go | 19 +- internal/config/parser.go | 128 ++----- internal/config/parser_test.go | 270 +++------------ internal/config/types.go | 57 +++- 13 files changed, 1092 insertions(+), 791 deletions(-) create mode 100644 internal/apply/strategy.go create mode 100644 internal/apply/strategy_recreate.go create mode 100644 internal/config/config_test.go diff --git a/cmd/apply.go b/cmd/apply.go index ca2e2fd..22c26d8 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -5,6 +5,7 @@ package cmd import ( "context" "fmt" + "log" "os" "path/filepath" "strings" @@ -54,7 +55,7 @@ func runApply(configPath string, isDryRun bool) error { // Step 2: Parse and validate configuration parser := config.NewParser() - cfg, err := parser.ParseFile(absPath) + cfg, manifestContent, err := parser.ParseFile(absPath) if err != nil { return fmt.Errorf("failed to parse configuration: %w", err) } @@ -119,8 +120,8 @@ func runApply(configPath string, isDryRun bool) error { // Step 9: Execute deployment fmt.Println("\nšŸš€ Starting deployment...") - manager := apply.NewResourceManager(client) - deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg) + manager := apply.NewResourceManager(client, apply.WithLogger(log.Default())) + deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent) if err != nil { return fmt.Errorf("deployment failed: %w", err) } diff --git a/internal/apply/manager.go b/internal/apply/manager.go index 21661c6..9951ada 100644 --- a/internal/apply/manager.go +++ b/internal/apply/manager.go @@ -5,9 +5,6 @@ package apply import ( "context" "fmt" - "io" - "os" - "sync" "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" @@ -17,7 +14,7 @@ import ( // 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) + ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) // RollbackDeployment attempts to rollback a failed deployment RollbackDeployment(ctx context.Context, result *ExecutionResult) error @@ -32,6 +29,7 @@ type EdgeConnectResourceManager struct { parallelLimit int rollbackOnFail bool logger Logger + strategyConfig StrategyConfig } // Logger interface for deployment logging @@ -52,6 +50,9 @@ type ResourceManagerOptions struct { // Timeout for individual operations OperationTimeout time.Duration + + // StrategyConfig for deployment strategies + StrategyConfig StrategyConfig } // DefaultResourceManagerOptions returns sensible defaults @@ -60,6 +61,7 @@ func DefaultResourceManagerOptions() ResourceManagerOptions { ParallelLimit: 5, // Conservative parallel limit RollbackOnFail: true, OperationTimeout: 2 * time.Minute, + StrategyConfig: DefaultStrategyConfig(), } } @@ -75,6 +77,7 @@ func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*Resourc parallelLimit: options.ParallelLimit, rollbackOnFail: options.RollbackOnFail, logger: options.Logger, + strategyConfig: options.StrategyConfig, } } @@ -99,321 +102,82 @@ func WithLogger(logger Logger) func(*ResourceManagerOptions) { } } -// 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{}, +// WithStrategyConfig sets the strategy configuration +func WithStrategyConfig(config StrategyConfig) func(*ResourceManagerOptions) { + return func(opts *ResourceManagerOptions) { + opts.StrategyConfig = config } +} + +// ApplyDeployment executes a deployment plan using deployment strategies +func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) { + rm.logf("Starting deployment: %s", plan.ConfigName) // 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) + result := &ExecutionResult{ + Plan: plan, + CompletedActions: []ActionResult{}, + FailedActions: []ActionResult{}, + Error: fmt.Errorf("prerequisites validation failed: %w", err), + Duration: 0, + } 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) + // Step 2: Determine deployment strategy + strategyName := DeploymentStrategy(config.Spec.GetDeploymentStrategy()) + rm.logf("Using deployment strategy: %s", strategyName) + + // Step 3: Create strategy executor + strategyConfig := rm.strategyConfig + strategyConfig.ParallelOperations = rm.parallelLimit > 1 + + factory := NewStrategyFactory(rm.client, strategyConfig, rm.logger) + strategy, err := factory.CreateStrategy(strategyName) + if err != nil { + result := &ExecutionResult{ + Plan: plan, + CompletedActions: []ActionResult{}, + FailedActions: []ActionResult{}, + Error: fmt.Errorf("failed to create deployment strategy: %w", err), + Duration: 0, + } + return result, err + } + + // Step 4: Validate strategy can handle this deployment + if err := strategy.Validate(plan); err != nil { + result := &ExecutionResult{ + Plan: plan, + CompletedActions: []ActionResult{}, + FailedActions: []ActionResult{}, + Error: fmt.Errorf("strategy validation failed: %w", err), + Duration: 0, + } + return result, err + } + + // Step 5: Execute the deployment strategy + rm.logf("Estimated deployment duration: %v", strategy.EstimateDuration(plan)) + result, err := strategy.Execute(ctx, plan, config, manifestContent) + + // Step 6: Handle rollback if needed + if err != nil && rm.rollbackOnFail && result != nil { + rm.logf("Deployment failed, attempting rollback...") + if rollbackErr := rm.RollbackDeployment(ctx, result); rollbackErr != nil { + rm.logf("Rollback failed: %v", rollbackErr) } 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 + result.RollbackPerformed = true + result.RollbackSuccess = true } } - // 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 { + if result != nil && result.Success { 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, - DeploymentGenerator: "kubernetes-basic", - }, - } - - // Add network configuration if specified - if config.Spec.Network != nil { - appInput.App.RequiredOutboundConnections = rm.convertNetworkRules(config.Spec.Network) - } - - // Create the application - if err := rm.client.CreateApp(ctx, appInput); err != nil { - return false, fmt.Errorf("failed to create application: %w", err) - } - - 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.Metadata.Name, - Version: config.Spec.GetAppVersion(), - }, - Flavor: edgeconnect.Flavor{ - Name: action.Target.FlavorName, - }, - }, - } - - // Create the instance - if err := rm.client.CreateAppInstance(ctx, instanceInput); err != nil { - return false, fmt.Errorf("failed to create instance: %w", err) - } - - 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 + return result, err } // ValidatePrerequisites checks if deployment prerequisites are met diff --git a/internal/apply/manager_test.go b/internal/apply/manager_test.go index bbc2aa2..17fd027 100644 --- a/internal/apply/manager_test.go +++ b/internal/apply/manager_test.go @@ -37,6 +37,16 @@ func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey edgeconnect.A return args.Error(0) } +func (m *MockResourceClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error { + args := m.Called(ctx, input) + return args.Error(0) +} + +func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error { + args := m.Called(ctx, input) + 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) @@ -157,10 +167,20 @@ func createTestManagerConfig(t *testing.T) *config.EdgeConnectConfig { } } +// createTestStrategyConfig returns a fast configuration for tests +func createTestStrategyConfig() StrategyConfig { + return StrategyConfig{ + MaxRetries: 0, // No retries for fast tests + HealthCheckTimeout: 1 * time.Millisecond, + ParallelOperations: false, // Sequential for predictable tests + RetryDelay: 0, // No delay + } +} + func TestApplyDeploymentSuccess(t *testing.T) { mockClient := &MockResourceClient{} logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger)) + manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) plan := createTestDeploymentPlan() config := createTestManagerConfig(t) @@ -172,7 +192,7 @@ func TestApplyDeploymentSuccess(t *testing.T) { Return(nil) ctx := context.Background() - result, err := manager.ApplyDeployment(ctx, plan, config) + result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content") require.NoError(t, err) require.NotNil(t, result) @@ -191,24 +211,24 @@ func TestApplyDeploymentSuccess(t *testing.T) { func TestApplyDeploymentAppFailure(t *testing.T) { mockClient := &MockResourceClient{} logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger)) + manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) plan := createTestDeploymentPlan() config := createTestManagerConfig(t) - // Mock app creation failure + // Mock app creation failure - deployment should stop here 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) + result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content") 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") + assert.Contains(t, err.Error(), "Server error") mockClient.AssertExpectations(t) } @@ -216,7 +236,7 @@ func TestApplyDeploymentAppFailure(t *testing.T) { func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { mockClient := &MockResourceClient{} logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true)) + manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig())) plan := createTestDeploymentPlan() config := createTestManagerConfig(t) @@ -232,7 +252,7 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { Return(nil) ctx := context.Background() - result, err := manager.ApplyDeployment(ctx, plan, config) + result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content") require.Error(t, err) require.NotNil(t, result) @@ -241,7 +261,7 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { 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") + assert.Contains(t, err.Error(), "failed to create instance") mockClient.AssertExpectations(t) } @@ -258,7 +278,7 @@ func TestApplyDeploymentNoActions(t *testing.T) { config := createTestManagerConfig(t) ctx := context.Background() - result, err := manager.ApplyDeployment(ctx, plan, config) + result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content") require.Error(t, err) require.NotNil(t, result) @@ -271,7 +291,7 @@ func TestApplyDeploymentNoActions(t *testing.T) { func TestApplyDeploymentMultipleInstances(t *testing.T) { mockClient := &MockResourceClient{} logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2)) + manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig())) // Create plan with multiple instances plan := &DeploymentPlan{ @@ -322,7 +342,7 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) { Return(nil) ctx := context.Background() - result, err := manager.ApplyDeployment(ctx, plan, config) + result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content") require.NoError(t, err) require.NotNil(t, result) @@ -382,7 +402,7 @@ func TestValidatePrerequisites(t *testing.T) { func TestRollbackDeployment(t *testing.T) { mockClient := &MockResourceClient{} logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger)) + manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) // Create result with completed actions plan := createTestDeploymentPlan() @@ -447,76 +467,7 @@ func TestRollbackDeploymentFailure(t *testing.T) { 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{ { @@ -534,7 +485,7 @@ func TestConvertNetworkRules(t *testing.T) { }, } - rules := manager.convertNetworkRules(network) + rules := convertNetworkRules(network) require.Len(t, rules, 2) assert.Equal(t, "tcp", rules[0].Protocol) @@ -547,47 +498,3 @@ func TestConvertNetworkRules(t *testing.T) { 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) -} diff --git a/internal/apply/planner.go b/internal/apply/planner.go index 0c94f85..93caf18 100644 --- a/internal/apply/planner.go +++ b/internal/apply/planner.go @@ -19,9 +19,11 @@ import ( type EdgeConnectClientInterface interface { ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error + UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error + UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error } @@ -144,6 +146,19 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E desired.AppType = AppTypeDocker } + // Extract outbound connections from config + if config.Spec.Network != nil { + desired.OutboundConnections = make([]SecurityRule, len(config.Spec.Network.OutboundConnections)) + for i, conn := range config.Spec.Network.OutboundConnections { + desired.OutboundConnections[i] = SecurityRule{ + Protocol: conn.Protocol, + PortRangeMin: conn.PortRangeMin, + PortRangeMax: conn.PortRangeMax, + RemoteCIDR: conn.RemoteCIDR, + } + } + } + // Calculate manifest hash manifestHash, err := p.calculateManifestHash(config.Spec.GetManifestFile()) if err != nil { @@ -299,6 +314,17 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap current.AppType = AppTypeDocker } + // Extract outbound connections from the app + current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections)) + for i, conn := range app.RequiredOutboundConnections { + current.OutboundConnections[i] = SecurityRule{ + Protocol: conn.Protocol, + PortRangeMin: conn.PortRangeMin, + PortRangeMax: conn.PortRangeMax, + RemoteCIDR: conn.RemoteCIDR, + } + } + return current, nil } @@ -357,9 +383,46 @@ func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]str changes = append(changes, fmt.Sprintf("App type changed: %s -> %s", current.AppType, desired.AppType)) } + // Compare outbound connections + if !p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections) { + changes = append(changes, "Outbound connections changed") + } + return changes, manifestChanged } +// compareOutboundConnections compares two sets of outbound connections for equality +func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) bool { + makeMap := func(rules []SecurityRule) map[string]struct{} { + m := make(map[string]struct{}, len(rules)) + for _, r := range rules { + key := fmt.Sprintf("%s:%d-%d:%s", + strings.ToLower(r.Protocol), + r.PortRangeMin, + r.PortRangeMax, + r.RemoteCIDR, + ) + m[key] = struct{}{} + } + return m + } + + currentMap := makeMap(current) + desiredMap := makeMap(desired) + + if len(currentMap) != len(desiredMap) { + return false + } + + for k := range currentMap { + if _, exists := desiredMap[k]; !exists { + return false + } + } + + return true +} + // compareInstanceStates compares current and desired instance states and returns changes func (p *EdgeConnectPlanner) compareInstanceStates(current, desired *InstanceState) []string { var changes []string diff --git a/internal/apply/planner_test.go b/internal/apply/planner_test.go index 7efb888..cd9ef31 100644 --- a/internal/apply/planner_test.go +++ b/internal/apply/planner_test.go @@ -52,6 +52,16 @@ func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey edgeconnec return args.Error(0) } +func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error { + args := m.Called(ctx, input) + return args.Error(0) +} + +func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error { + args := m.Called(ctx, input) + 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) @@ -174,7 +184,7 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) { // Note: We would calculate expected manifest hash here when API supports it - // Mock existing app with same manifest hash + // Mock existing app with same manifest hash and outbound connections existingApp := &edgeconnect.App{ Key: edgeconnect.AppKey{ Organization: "testorg", @@ -182,6 +192,14 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) { Version: "1.0.0", }, Deployment: "kubernetes", + RequiredOutboundConnections: []edgeconnect.SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + }, // Note: Manifest hash tracking would be implemented when API supports annotations } @@ -231,12 +249,6 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) { 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) @@ -389,6 +401,104 @@ func TestCompareAppStates(t *testing.T) { assert.Contains(t, changes[0], "App type changed") } +func TestCompareAppStatesOutboundConnections(t *testing.T) { + planner := &EdgeConnectPlanner{} + + // Test with no outbound connections + current := &AppState{ + Name: "test-app", + Version: "1.0.0", + AppType: AppTypeK8s, + OutboundConnections: nil, + } + + desired := &AppState{ + Name: "test-app", + Version: "1.0.0", + AppType: AppTypeK8s, + OutboundConnections: nil, + } + + changes, _ := planner.compareAppStates(current, desired) + assert.Empty(t, changes, "No changes expected when both have no outbound connections") + + // Test adding outbound connections + desired.OutboundConnections = []SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + } + + changes, _ = planner.compareAppStates(current, desired) + assert.Len(t, changes, 1) + assert.Contains(t, changes[0], "Outbound connections changed") + + // Test identical outbound connections + current.OutboundConnections = []SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + } + + changes, _ = planner.compareAppStates(current, desired) + assert.Empty(t, changes, "No changes expected when outbound connections are identical") + + // Test different outbound connections (different port) + desired.OutboundConnections[0].PortRangeMin = 443 + desired.OutboundConnections[0].PortRangeMax = 443 + + changes, _ = planner.compareAppStates(current, desired) + assert.Len(t, changes, 1) + assert.Contains(t, changes[0], "Outbound connections changed") + + // Test same connections but different order (should be considered equal) + current.OutboundConnections = []SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + { + Protocol: "tcp", + PortRangeMin: 443, + PortRangeMax: 443, + RemoteCIDR: "0.0.0.0/0", + }, + } + + desired.OutboundConnections = []SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 443, + PortRangeMax: 443, + RemoteCIDR: "0.0.0.0/0", + }, + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + } + + changes, _ = planner.compareAppStates(current, desired) + assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order") + + // Test removing outbound connections + desired.OutboundConnections = nil + + changes, _ = planner.compareAppStates(current, desired) + assert.Len(t, changes, 1) + assert.Contains(t, changes[0], "Outbound connections changed") +} + func TestCompareInstanceStates(t *testing.T) { planner := &EdgeConnectPlanner{} diff --git a/internal/apply/strategy.go b/internal/apply/strategy.go new file mode 100644 index 0000000..8d32d2e --- /dev/null +++ b/internal/apply/strategy.go @@ -0,0 +1,106 @@ +// ABOUTME: Deployment strategy framework for EdgeConnect apply command +// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling) +package apply + +import ( + "context" + "fmt" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" +) + +// DeploymentStrategy represents the type of deployment strategy +type DeploymentStrategy string + +const ( + // StrategyRecreate deletes all instances, updates app, then creates new instances + StrategyRecreate DeploymentStrategy = "recreate" + + // StrategyBlueGreen creates new instances alongside old ones, then switches traffic (future) + StrategyBlueGreen DeploymentStrategy = "blue-green" + + // StrategyRolling updates instances one by one with health checks (future) + StrategyRolling DeploymentStrategy = "rolling" +) + +// DeploymentStrategyExecutor defines the interface that all deployment strategies must implement +type DeploymentStrategyExecutor interface { + // Execute runs the deployment strategy + Execute(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) + + // Validate checks if the strategy can be used for this deployment + Validate(plan *DeploymentPlan) error + + // EstimateDuration provides time estimate for this strategy + EstimateDuration(plan *DeploymentPlan) time.Duration + + // GetName returns the strategy name + GetName() DeploymentStrategy +} + +// StrategyConfig holds configuration for deployment strategies +type StrategyConfig struct { + // MaxRetries is the number of times to retry failed operations + MaxRetries int + + // HealthCheckTimeout is the maximum time to wait for health checks + HealthCheckTimeout time.Duration + + // ParallelOperations enables parallel execution of operations + ParallelOperations bool + + // RetryDelay is the delay between retry attempts + RetryDelay time.Duration +} + +// DefaultStrategyConfig returns sensible defaults for strategy configuration +func DefaultStrategyConfig() StrategyConfig { + return StrategyConfig{ + MaxRetries: 5, // Retry 5 times + HealthCheckTimeout: 5 * time.Minute, // Max 5 mins health check + ParallelOperations: true, // Parallel execution + RetryDelay: 10 * time.Second, // 10s between retries + } +} + +// StrategyFactory creates deployment strategy executors +type StrategyFactory struct { + config StrategyConfig + client EdgeConnectClientInterface + logger Logger +} + +// NewStrategyFactory creates a new strategy factory +func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *StrategyFactory { + return &StrategyFactory{ + config: config, + client: client, + logger: logger, + } +} + +// CreateStrategy creates the appropriate strategy executor based on the deployment strategy +func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) { + switch strategy { + case StrategyRecreate: + return NewRecreateStrategy(f.client, f.config, f.logger), nil + case StrategyBlueGreen: + // TODO: Implement blue-green strategy + return nil, fmt.Errorf("blue-green strategy not yet implemented") + case StrategyRolling: + // TODO: Implement rolling strategy + return nil, fmt.Errorf("rolling strategy not yet implemented") + default: + return nil, fmt.Errorf("unknown deployment strategy: %s", strategy) + } +} + +// GetAvailableStrategies returns a list of all available strategies +func (f *StrategyFactory) GetAvailableStrategies() []DeploymentStrategy { + return []DeploymentStrategy{ + StrategyRecreate, + // StrategyBlueGreen, // TODO: Enable when implemented + // StrategyRolling, // TODO: Enable when implemented + } +} diff --git a/internal/apply/strategy_recreate.go b/internal/apply/strategy_recreate.go new file mode 100644 index 0000000..da88664 --- /dev/null +++ b/internal/apply/strategy_recreate.go @@ -0,0 +1,505 @@ +// ABOUTME: Recreate deployment strategy implementation for EdgeConnect +// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution +package apply + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" +) + +// RecreateStrategy implements the recreate deployment strategy +type RecreateStrategy struct { + client EdgeConnectClientInterface + config StrategyConfig + logger Logger +} + +// NewRecreateStrategy creates a new recreate strategy executor +func NewRecreateStrategy(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *RecreateStrategy { + return &RecreateStrategy{ + client: client, + config: config, + logger: logger, + } +} + +// GetName returns the strategy name +func (r *RecreateStrategy) GetName() DeploymentStrategy { + return StrategyRecreate +} + +// Validate checks if the recreate strategy can be used for this deployment +func (r *RecreateStrategy) Validate(plan *DeploymentPlan) error { + // Recreate strategy can be used for any deployment + // No specific constraints for recreate + return nil +} + +// EstimateDuration estimates the time needed for recreate deployment +func (r *RecreateStrategy) EstimateDuration(plan *DeploymentPlan) time.Duration { + var duration time.Duration + + // Delete phase - estimate based on number of instances + instanceCount := len(plan.InstanceActions) + if instanceCount > 0 { + deleteTime := time.Duration(instanceCount) * 30 * time.Second + if r.config.ParallelOperations { + deleteTime = 30 * time.Second // Parallel deletion + } + duration += deleteTime + } + + // App update phase + if plan.AppAction.Type == ActionUpdate { + duration += 30 * time.Second + } + + // Create phase - estimate based on number of instances + if instanceCount > 0 { + createTime := time.Duration(instanceCount) * 2 * time.Minute + if r.config.ParallelOperations { + createTime = 2 * time.Minute // Parallel creation + } + duration += createTime + } + + // Health check time + duration += r.config.HealthCheckTimeout + + // Add retry buffer (potential retries) + retryBuffer := time.Duration(r.config.MaxRetries) * r.config.RetryDelay + duration += retryBuffer + + return duration +} + +// Execute runs the recreate deployment strategy +func (r *RecreateStrategy) Execute(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) { + startTime := time.Now() + r.logf("Starting recreate deployment strategy for: %s", plan.ConfigName) + + result := &ExecutionResult{ + Plan: plan, + CompletedActions: []ActionResult{}, + FailedActions: []ActionResult{}, + } + + // Phase 1: Delete all existing instances + if err := r.deleteInstancesPhase(ctx, plan, config, result); err != nil { + result.Error = err + result.Duration = time.Since(startTime) + return result, err + } + + // Phase 2: Delete existing app (if updating) + if err := r.deleteAppPhase(ctx, plan, config, result); err != nil { + result.Error = err + result.Duration = time.Since(startTime) + return result, err + } + + // Phase 3: Create/recreate application + if err := r.createAppPhase(ctx, plan, config, manifestContent, result); err != nil { + result.Error = err + result.Duration = time.Since(startTime) + return result, err + } + + // Phase 4: Create new instances + if err := r.createInstancesPhase(ctx, plan, config, result); err != nil { + result.Error = err + result.Duration = time.Since(startTime) + return result, err + } + + // Phase 5: Health check (wait for instances to be ready) + if err := r.healthCheckPhase(ctx, plan, result); err != nil { + result.Error = err + result.Duration = time.Since(startTime) + return result, err + } + + result.Success = len(result.FailedActions) == 0 + result.Duration = time.Since(startTime) + + if result.Success { + r.logf("Recreate deployment completed successfully in %v", result.Duration) + } else { + r.logf("Recreate deployment failed with %d failed actions", len(result.FailedActions)) + } + + return result, result.Error +} + +// deleteInstancesPhase deletes all existing instances +func (r *RecreateStrategy) deleteInstancesPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error { + r.logf("Phase 1: Deleting existing instances") + + // Only delete instances that exist (have ActionUpdate or ActionNone type) + instancesToDelete := []InstanceAction{} + for _, action := range plan.InstanceActions { + if action.Type == ActionUpdate || action.Type == ActionNone { + // Convert to delete action + deleteAction := action + deleteAction.Type = ActionDelete + deleteAction.Reason = "Recreate strategy: deleting for recreation" + instancesToDelete = append(instancesToDelete, deleteAction) + } + } + + if len(instancesToDelete) == 0 { + r.logf("No existing instances to delete") + return nil + } + + deleteResults := r.executeInstanceActionsWithRetry(ctx, instancesToDelete, "delete", config) + + for _, deleteResult := range deleteResults { + if deleteResult.Success { + result.CompletedActions = append(result.CompletedActions, deleteResult) + r.logf("Deleted instance: %s", deleteResult.Target) + } else { + result.FailedActions = append(result.FailedActions, deleteResult) + return fmt.Errorf("failed to delete instance %s: %w", deleteResult.Target, deleteResult.Error) + } + } + + r.logf("Phase 1 complete: deleted %d instances", len(deleteResults)) + return nil +} + +// deleteAppPhase deletes the existing app (if updating) +func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error { + if plan.AppAction.Type != ActionUpdate { + r.logf("Phase 2: No app deletion needed (new app)") + return nil + } + + r.logf("Phase 2: Deleting existing application") + + appKey := edgeconnect.AppKey{ + Organization: plan.AppAction.Desired.Organization, + Name: plan.AppAction.Desired.Name, + Version: plan.AppAction.Desired.Version, + } + + if err := r.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region); err != nil { + result.FailedActions = append(result.FailedActions, ActionResult{ + Type: ActionDelete, + Target: plan.AppAction.Desired.Name, + Success: false, + Error: err, + }) + return fmt.Errorf("failed to delete app: %w", err) + } + + result.CompletedActions = append(result.CompletedActions, ActionResult{ + Type: ActionDelete, + Target: plan.AppAction.Desired.Name, + Success: true, + Details: fmt.Sprintf("Deleted app %s", plan.AppAction.Desired.Name), + }) + + r.logf("Phase 2 complete: deleted existing application") + return nil +} + +// createAppPhase creates the application (always create since we deleted it first) +func (r *RecreateStrategy) createAppPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string, result *ExecutionResult) error { + if plan.AppAction.Type == ActionNone { + r.logf("Phase 3: No app creation needed") + return nil + } + + r.logf("Phase 3: Creating application") + + // Always use create since recreate strategy deletes first + createAction := plan.AppAction + createAction.Type = ActionCreate + createAction.Reason = "Recreate strategy: creating app" + + appResult := r.executeAppActionWithRetry(ctx, createAction, config, manifestContent) + + if appResult.Success { + result.CompletedActions = append(result.CompletedActions, appResult) + r.logf("Phase 3 complete: app created successfully") + return nil + } else { + result.FailedActions = append(result.FailedActions, appResult) + return fmt.Errorf("failed to create app: %w", appResult.Error) + } +} + +// createInstancesPhase creates new instances +func (r *RecreateStrategy) createInstancesPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error { + r.logf("Phase 4: Creating new instances") + + // Convert all instance actions to create + instancesToCreate := []InstanceAction{} + for _, action := range plan.InstanceActions { + createAction := action + createAction.Type = ActionCreate + createAction.Reason = "Recreate strategy: creating new instance" + instancesToCreate = append(instancesToCreate, createAction) + } + + if len(instancesToCreate) == 0 { + r.logf("No instances to create") + return nil + } + + createResults := r.executeInstanceActionsWithRetry(ctx, instancesToCreate, "create", config) + + for _, createResult := range createResults { + if createResult.Success { + result.CompletedActions = append(result.CompletedActions, createResult) + r.logf("Created instance: %s", createResult.Target) + } else { + result.FailedActions = append(result.FailedActions, createResult) + return fmt.Errorf("failed to create instance %s: %w", createResult.Target, createResult.Error) + } + } + + r.logf("Phase 4 complete: created %d instances", len(createResults)) + return nil +} + +// healthCheckPhase waits for instances to become ready +func (r *RecreateStrategy) healthCheckPhase(ctx context.Context, plan *DeploymentPlan, result *ExecutionResult) error { + if len(plan.InstanceActions) == 0 { + return nil + } + + r.logf("Phase 5: Performing health checks") + + // TODO: Implement actual health checks by querying instance status + // For now, skip waiting in tests/mock environments + r.logf("Phase 5 complete: health check passed (no wait)") + return nil +} + +// executeInstanceActionsWithRetry executes instance actions with retry logic +func (r *RecreateStrategy) executeInstanceActionsWithRetry(ctx context.Context, actions []InstanceAction, operation string, config *config.EdgeConnectConfig) []ActionResult { + results := make([]ActionResult, len(actions)) + + if r.config.ParallelOperations && len(actions) > 1 { + // Parallel execution + var wg sync.WaitGroup + semaphore := make(chan struct{}, 5) // Limit concurrency + + for i, action := range actions { + wg.Add(1) + go func(index int, instanceAction InstanceAction) { + defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + + results[index] = r.executeInstanceActionWithRetry(ctx, instanceAction, operation, config) + }(i, action) + } + wg.Wait() + } else { + // Sequential execution + for i, action := range actions { + results[i] = r.executeInstanceActionWithRetry(ctx, action, operation, config) + } + } + + return results +} + +// executeInstanceActionWithRetry executes a single instance action with retry logic +func (r *RecreateStrategy) executeInstanceActionWithRetry(ctx context.Context, action InstanceAction, operation string, config *config.EdgeConnectConfig) ActionResult { + startTime := time.Now() + result := ActionResult{ + Type: action.Type, + Target: action.InstanceName, + } + + var lastErr error + for attempt := 0; attempt <= r.config.MaxRetries; attempt++ { + if attempt > 0 { + r.logf("Retrying %s for instance %s (attempt %d/%d)", operation, action.InstanceName, attempt, r.config.MaxRetries) + select { + case <-time.After(r.config.RetryDelay): + case <-ctx.Done(): + result.Error = ctx.Err() + result.Duration = time.Since(startTime) + return result + } + } + + var success bool + var err error + + switch action.Type { + case ActionDelete: + success, err = r.deleteInstance(ctx, action) + case ActionCreate: + success, err = r.createInstance(ctx, action, config) + default: + err = fmt.Errorf("unsupported action type: %s", action.Type) + } + + if success { + result.Success = true + result.Details = fmt.Sprintf("Successfully %sd instance %s", strings.ToLower(string(action.Type)), action.InstanceName) + result.Duration = time.Since(startTime) + return result + } + + lastErr = err + if attempt < r.config.MaxRetries { + r.logf("Failed to %s instance %s: %v (will retry)", operation, action.InstanceName, err) + } + } + + result.Error = fmt.Errorf("failed after %d attempts: %w", r.config.MaxRetries+1, lastErr) + result.Duration = time.Since(startTime) + return result +} + +// executeAppActionWithRetry executes app action with retry logic +func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) ActionResult { + startTime := time.Now() + result := ActionResult{ + Type: action.Type, + Target: action.Desired.Name, + } + + var lastErr error + for attempt := 0; attempt <= r.config.MaxRetries; attempt++ { + if attempt > 0 { + r.logf("Retrying app update (attempt %d/%d)", attempt, r.config.MaxRetries) + select { + case <-time.After(r.config.RetryDelay): + case <-ctx.Done(): + result.Error = ctx.Err() + result.Duration = time.Since(startTime) + return result + } + } + + success, err := r.updateApplication(ctx, action, config, manifestContent) + if success { + result.Success = true + result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name) + result.Duration = time.Since(startTime) + return result + } + + lastErr = err + if attempt < r.config.MaxRetries { + r.logf("Failed to update app: %v (will retry)", err) + } + } + + result.Error = fmt.Errorf("failed after %d attempts: %w", r.config.MaxRetries+1, lastErr) + result.Duration = time.Since(startTime) + return result +} + +// deleteInstance deletes an instance (reuse existing logic from manager.go) +func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) { + instanceKey := edgeconnect.AppInstanceKey{ + Organization: action.Target.Organization, + Name: action.InstanceName, + CloudletKey: edgeconnect.CloudletKey{ + Organization: action.Target.CloudletOrg, + Name: action.Target.CloudletName, + }, + } + + err := r.client.DeleteAppInstance(ctx, instanceKey, action.Target.Region) + if err != nil { + return false, fmt.Errorf("failed to delete instance: %w", err) + } + + return true, nil +} + +// createInstance creates an instance (extracted from manager.go logic) +func (r *RecreateStrategy) 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.Metadata.Name, + Version: config.Spec.GetAppVersion(), + }, + Flavor: edgeconnect.Flavor{ + Name: action.Target.FlavorName, + }, + }, + } + + // Create the instance + if err := r.client.CreateAppInstance(ctx, instanceInput); err != nil { + return false, fmt.Errorf("failed to create instance: %w", err) + } + + r.logf("Successfully created instance: %s on %s:%s", + action.InstanceName, action.Target.CloudletOrg, action.Target.CloudletName) + + return true, nil +} + +// updateApplication creates/recreates an application (always uses CreateApp since we delete first) +func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) { + // Build the app create input - always create since recreate strategy deletes first + 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: config.GetDeploymentType(), + ImageType: "ImageTypeDocker", + ImagePath: config.GetImagePath(), + AllowServerless: true, + DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, + ServerlessConfig: struct{}{}, + DeploymentManifest: manifestContent, + DeploymentGenerator: "kubernetes-basic", + }, + } + + // Add network configuration if specified + if config.Spec.Network != nil { + appInput.App.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network) + } + + // Create the application (recreate strategy always creates from scratch) + if err := r.client.CreateApp(ctx, appInput); err != nil { + return false, fmt.Errorf("failed to create application: %w", err) + } + + r.logf("Successfully created application: %s/%s version %s", + action.Desired.Organization, action.Desired.Name, action.Desired.Version) + + return true, nil +} + +// logf logs a message if a logger is configured +func (r *RecreateStrategy) logf(format string, v ...interface{}) { + if r.logger != nil { + r.logger.Printf("[RecreateStrategy] "+format, v...) + } +} diff --git a/internal/apply/types.go b/internal/apply/types.go index a958d8e..cd2a93a 100644 --- a/internal/apply/types.go +++ b/internal/apply/types.go @@ -7,8 +7,12 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" ) +// SecurityRule defines network access rules (alias to SDK type for consistency) +type SecurityRule = edgeconnect.SecurityRule + // ActionType represents the type of action to be performed type ActionType string @@ -131,6 +135,9 @@ type AppState struct { // AppType indicates whether this is a k8s or docker app AppType AppType + + // OutboundConnections contains the required outbound network connections + OutboundConnections []SecurityRule } // InstanceState represents the current state of an application instance @@ -426,3 +433,19 @@ func (dp *DeploymentPlan) Clone() *DeploymentPlan { return clone } + +// convertNetworkRules converts config network rules to EdgeConnect SecurityRules +func 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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..75c1747 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,46 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetDeploymentType(t *testing.T) { + // Test k8s app + k8sConfig := &EdgeConnectConfig{ + Spec: Spec{ + K8sApp: &K8sApp{}, + }, + } + assert.Equal(t, "kubernetes", k8sConfig.GetDeploymentType()) + + // Test docker app + dockerConfig := &EdgeConnectConfig{ + Spec: Spec{ + DockerApp: &DockerApp{}, + }, + } + assert.Equal(t, "docker", dockerConfig.GetDeploymentType()) +} + +func TestGetImagePath(t *testing.T) { + + // Test docker app with image + dockerConfig := &EdgeConnectConfig{ + Spec: Spec{ + DockerApp: &DockerApp{ + Image: "my-custom-image:latest", + }, + }, + } + assert.Equal(t, "my-custom-image:latest", dockerConfig.GetImagePath()) + + // Test k8s app (should use default) + k8sConfig := &EdgeConnectConfig{ + Spec: Spec{ + K8sApp: &K8sApp{}, + }, + } + assert.Equal(t, "https://registry-1.docker.io/library/nginx:latest", k8sConfig.GetImagePath()) +} diff --git a/internal/config/example_test.go b/internal/config/example_test.go index 2cc4f27..7219412 100644 --- a/internal/config/example_test.go +++ b/internal/config/example_test.go @@ -15,11 +15,12 @@ func TestParseExampleConfig(t *testing.T) { // Parse the actual example file (now that we've created the manifest file) examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig.yaml") - config, err := parser.ParseFile(examplePath) + config, parsedManifest, err := parser.ParseFile(examplePath) // This should now succeed with full validation require.NoError(t, err) require.NotNil(t, config) + require.NotEmpty(t, parsedManifest) // Validate the parsed structure assert.Equal(t, "edgeconnect-deployment", config.Kind) @@ -62,10 +63,6 @@ func TestParseExampleConfig(t *testing.T) { 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.Metadata.Name, config.Spec.GetAppVersion()) - assert.Equal(t, "edge-app-demo-1.0.0-instance", instanceName) } func TestValidateExampleStructure(t *testing.T) { @@ -113,16 +110,4 @@ func TestValidateExampleStructure(t *testing.T) { // 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) } diff --git a/internal/config/parser.go b/internal/config/parser.go index 238c22e..28e0ed0 100644 --- a/internal/config/parser.go +++ b/internal/config/parser.go @@ -12,7 +12,7 @@ import ( // Parser defines the interface for configuration parsing type Parser interface { - ParseFile(filename string) (*EdgeConnectConfig, error) + ParseFile(filename string) (*EdgeConnectConfig, string, error) ParseBytes(data []byte) (*EdgeConnectConfig, error) Validate(config *EdgeConnectConfig) error } @@ -26,40 +26,45 @@ func NewParser() Parser { } // ParseFile parses an EdgeConnectConfig from a YAML file -func (p *ConfigParser) ParseFile(filename string) (*EdgeConnectConfig, error) { +func (p *ConfigParser) ParseFile(filename string) (*EdgeConnectConfig, string, error) { if filename == "" { - return nil, fmt.Errorf("filename cannot be empty") + 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) + 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) + 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) + 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) + 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 nil, "", fmt.Errorf("configuration validation failed in %s: %w", filename, err) } - return config, nil + manifest, err := p.readManifestFiles(config) + if err != nil { + return nil, "", fmt.Errorf("failed to read manifest files: %w", err) + } + + return config, manifest, nil } // parseYAMLOnly parses YAML without validation @@ -122,7 +127,7 @@ func (p *ConfigParser) resolveRelativePaths(config *EdgeConnectConfig, configDir } // ValidateManifestFiles performs additional validation on manifest files -func (p *ConfigParser) ValidateManifestFiles(config *EdgeConnectConfig) error { +func (p *ConfigParser) readManifestFiles(config *EdgeConnectConfig) (string, error) { var manifestFile string if config.Spec.K8sApp != nil { @@ -131,13 +136,23 @@ func (p *ConfigParser) ValidateManifestFiles(config *EdgeConnectConfig) error { manifestFile = config.Spec.DockerApp.ManifestFile } + if manifestFile == "" { + return "", nil + } + if manifestFile != "" { if err := p.validateManifestFile(manifestFile); err != nil { - return fmt.Errorf("manifest file validation failed: %w", err) + return "", fmt.Errorf("manifest file validation failed: %w", err) } } - return nil + // Try to read the file to ensure it's accessible + content, err := os.ReadFile(manifestFile) + if err != nil { + return "", fmt.Errorf("cannot read manifest file %s: %w", manifestFile, err) + } + + return string(content), nil } // validateManifestFile checks if the manifest file is valid and readable @@ -155,94 +170,5 @@ func (p *ConfigParser) validateManifestFile(filename string) error { 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/internal/config/parser_test.go b/internal/config/parser_test.go index 79b149a..d5e0865 100644 --- a/internal/config/parser_test.go +++ b/internal/config/parser_test.go @@ -239,7 +239,7 @@ spec: require.NoError(t, err) // Test valid file parsing - config, err := parser.ParseFile(validFile) + config, _, err := parser.ParseFile(validFile) assert.NoError(t, err) assert.NotNil(t, config) assert.Equal(t, "edgeconnect-deployment", config.Kind) @@ -247,13 +247,13 @@ spec: // Test non-existent file nonExistentFile := filepath.Join(tempDir, "nonexistent.yaml") - config, err = parser.ParseFile(nonExistentFile) + 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("") + config, _, err = parser.ParseFile("") assert.Error(t, err) assert.Contains(t, err.Error(), "filename cannot be empty") assert.Nil(t, config) @@ -263,7 +263,7 @@ spec: err = os.WriteFile(invalidFile, []byte("invalid: yaml: content: ["), 0644) require.NoError(t, err) - config, err = parser.ParseFile(invalidFile) + config, _, err = parser.ParseFile(invalidFile) assert.Error(t, err) assert.Contains(t, err.Error(), "YAML parsing failed") assert.Nil(t, config) @@ -300,7 +300,7 @@ spec: err = os.WriteFile(configFile, []byte(configContent), 0644) require.NoError(t, err) - config, err := parser.ParseFile(configFile) + config, _, err := parser.ParseFile(configFile) assert.NoError(t, err) assert.NotNil(t, config) @@ -523,206 +523,6 @@ func TestOutboundConnection_Validate(t *testing.T) { } } -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{ @@ -749,27 +549,43 @@ func TestSpec_GetMethods(t *testing.T) { 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}, - } +func TestReadManifestFile(t *testing.T) { + parser := NewParser() + tempDir := t.TempDir() - 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) - }) - } + // 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: + 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, parsedManifestContent, err := parser.ParseFile(configFile) + assert.NoError(t, err) + assert.Equal(t, manifestContent, parsedManifestContent) + 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) } diff --git a/internal/config/types.go b/internal/config/types.go index 3c4a4eb..71388d0 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -23,10 +23,11 @@ type Metadata struct { // 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 *K8sApp `yaml:"k8sApp,omitempty"` + DockerApp *DockerApp `yaml:"dockerApp,omitempty"` + InfraTemplate []InfraTemplate `yaml:"infraTemplate"` + Network *NetworkConfig `yaml:"network,omitempty"` + DeploymentStrategy string `yaml:"deploymentStrategy,omitempty"` } // K8sApp defines Kubernetes application configuration @@ -85,6 +86,23 @@ func (c *EdgeConnectConfig) Validate() error { return nil } +// getDeploymentType determines the deployment type from config +func (c *EdgeConnectConfig) GetDeploymentType() string { + if c.Spec.IsK8sApp() { + return "kubernetes" + } + return "docker" +} + +// getImagePath gets the image path for the application +func (c *EdgeConnectConfig) GetImagePath() string { + if c.Spec.IsDockerApp() && c.Spec.DockerApp.Image != "" { + return c.Spec.DockerApp.Image + } + // Default for kubernetes apps + return "https://registry-1.docker.io/library/nginx:latest" +} + // Validate validates metadata fields func (m *Metadata) Validate() error { if m.Name == "" { @@ -141,6 +159,13 @@ func (s *Spec) Validate() error { } } + // Validate deployment strategy if present + if s.DeploymentStrategy != "" { + if err := s.ValidateDeploymentStrategy(); err != nil { + return fmt.Errorf("deploymentStrategy validation failed: %w", err) + } + } + return nil } @@ -332,3 +357,27 @@ func (s *Spec) IsK8sApp() bool { func (s *Spec) IsDockerApp() bool { return s.DockerApp != nil } + +// ValidateDeploymentStrategy validates the deployment strategy value +func (s *Spec) ValidateDeploymentStrategy() error { + validStrategies := map[string]bool{ + "recreate": true, + "blue-green": true, // Future implementation + "rolling": true, // Future implementation + } + + strategy := strings.ToLower(strings.TrimSpace(s.DeploymentStrategy)) + if !validStrategies[strategy] { + return fmt.Errorf("deploymentStrategy must be one of: recreate, blue-green, rolling") + } + + return nil +} + +// GetDeploymentStrategy returns the deployment strategy, defaulting to "recreate" +func (s *Spec) GetDeploymentStrategy() string { + if s.DeploymentStrategy == "" { + return "recreate" + } + return strings.ToLower(strings.TrimSpace(s.DeploymentStrategy)) +}