diff --git a/cmd/apply.go b/cmd/apply.go index 3a50ddf..1493841 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -10,7 +10,8 @@ import ( "path/filepath" "strings" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply" + applyv1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply/v1" + applyv2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply/v2" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "github.com/spf13/cobra" ) @@ -67,22 +68,27 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { fmt.Printf("āœ… Configuration loaded successfully: %s\n", cfg.Metadata.Name) - // Step 3: Validate API version (apply only supports v2) + // Step 3: Determine API version and create appropriate client apiVersion := getAPIVersion() + + // Step 4-6: Execute deployment based on API version if apiVersion == "v1" { - return fmt.Errorf("apply command only supports API v2. The v1 API does not support the advanced deployment features required by this command. Please use --api-version v2 or remove the api_version setting") + return runApplyV1(cfg, manifestContent, isDryRun, autoApprove) } + return runApplyV2(cfg, manifestContent, isDryRun, autoApprove) +} - // Step 4: Create EdgeConnect client (v2 only) - client := newSDKClientV2() +func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error { + // Create v1 client + client := newSDKClientV1() - // Step 5: Create deployment planner - planner := apply.NewPlanner(client) + // Create deployment planner + planner := applyv1.NewPlanner(client) - // Step 6: Generate deployment plan + // Generate deployment plan fmt.Println("šŸ” Analyzing current state and generating deployment plan...") - planOptions := apply.DefaultPlanOptions() + planOptions := applyv1.DefaultPlanOptions() planOptions.DryRun = isDryRun result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions) @@ -90,7 +96,7 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { return fmt.Errorf("failed to generate deployment plan: %w", err) } - // Step 6: Display plan summary + // Display plan summary fmt.Println("\nšŸ“‹ Deployment Plan:") fmt.Println(strings.Repeat("=", 50)) fmt.Println(result.Plan.Summary) @@ -104,13 +110,13 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { } } - // Step 7: If dry-run, stop here + // If dry-run, stop here if isDryRun { fmt.Println("\nšŸ” Dry-run complete. No changes were made.") return nil } - // Step 8: Confirm deployment (in non-dry-run mode) + // Confirm deployment if result.Plan.TotalActions == 0 { fmt.Println("\nāœ… No changes needed. Resources are already in desired state.") return nil @@ -124,16 +130,112 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { return nil } - // Step 9: Execute deployment + // Execute deployment fmt.Println("\nšŸš€ Starting deployment...") - manager := apply.NewResourceManager(client, apply.WithLogger(log.Default())) + manager := applyv1.NewResourceManager(client, applyv1.WithLogger(log.Default())) deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent) if err != nil { return fmt.Errorf("deployment failed: %w", err) } - // Step 10: Display results + // Display results + return displayDeploymentResults(deployResult) +} + +func runApplyV2(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error { + // Create v2 client + client := newSDKClientV2() + + // Create deployment planner + planner := applyv2.NewPlanner(client) + + // Generate deployment plan + fmt.Println("šŸ” Analyzing current state and generating deployment plan...") + + planOptions := applyv2.DefaultPlanOptions() + planOptions.DryRun = isDryRun + + result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions) + if err != nil { + return fmt.Errorf("failed to generate deployment plan: %w", err) + } + + // Display plan summary + fmt.Println("\nšŸ“‹ Deployment Plan:") + fmt.Println(strings.Repeat("=", 50)) + fmt.Println(result.Plan.Summary) + fmt.Println(strings.Repeat("=", 50)) + + // Display warnings if any + if len(result.Warnings) > 0 { + fmt.Println("\nāš ļø Warnings:") + for _, warning := range result.Warnings { + fmt.Printf(" • %s\n", warning) + } + } + + // If dry-run, stop here + if isDryRun { + fmt.Println("\nšŸ” Dry-run complete. No changes were made.") + return nil + } + + // Confirm deployment + if result.Plan.TotalActions == 0 { + fmt.Println("\nāœ… No changes needed. Resources are already in desired state.") + return nil + } + + fmt.Printf("\nThis will perform %d actions. Estimated time: %v\n", + result.Plan.TotalActions, result.Plan.EstimatedDuration) + + if !autoApprove && !confirmDeployment() { + fmt.Println("Deployment cancelled.") + return nil + } + + // Execute deployment + fmt.Println("\nšŸš€ Starting deployment...") + + manager := applyv2.NewResourceManager(client, applyv2.WithLogger(log.Default())) + deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent) + if err != nil { + return fmt.Errorf("deployment failed: %w", err) + } + + // Display results + return displayDeploymentResults(deployResult) +} + +type deploymentResult interface { + IsSuccess() bool + GetDuration() string + GetCompletedActions() []actionResult + GetFailedActions() []actionResult + GetError() error +} + +type actionResult interface { + GetType() string + GetTarget() string + GetError() error +} + +func displayDeploymentResults(result interface{}) error { + // Use reflection or type assertion to handle both v1 and v2 result types + // For now, we'll use a simple approach that works with both + switch r := result.(type) { + case *applyv1.ExecutionResult: + return displayDeploymentResultsV1(r) + case *applyv2.ExecutionResult: + return displayDeploymentResultsV2(r) + default: + return fmt.Errorf("unknown deployment result type") + } +} + +func displayDeploymentResultsV1(deployResult *applyv1.ExecutionResult) error { if deployResult.Success { fmt.Printf("\nāœ… Deployment completed successfully in %v\n", deployResult.Duration) if len(deployResult.CompletedActions) > 0 { @@ -155,7 +257,31 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { } return fmt.Errorf("deployment failed with %d failed actions", len(deployResult.FailedActions)) } + return nil +} +func displayDeploymentResultsV2(deployResult *applyv2.ExecutionResult) error { + if deployResult.Success { + fmt.Printf("\nāœ… Deployment completed successfully in %v\n", deployResult.Duration) + if len(deployResult.CompletedActions) > 0 { + fmt.Println("\nCompleted actions:") + for _, action := range deployResult.CompletedActions { + fmt.Printf(" āœ… %s %s\n", action.Type, action.Target) + } + } + } else { + fmt.Printf("\nāŒ Deployment failed after %v\n", deployResult.Duration) + if deployResult.Error != nil { + fmt.Printf("Error: %v\n", deployResult.Error) + } + if len(deployResult.FailedActions) > 0 { + fmt.Println("\nFailed actions:") + for _, action := range deployResult.FailedActions { + fmt.Printf(" āŒ %s %s: %v\n", action.Type, action.Target, action.Error) + } + } + return fmt.Errorf("deployment failed with %d failed actions", len(deployResult.FailedActions)) + } return nil } diff --git a/internal/apply/v1/manager.go b/internal/apply/v1/manager.go new file mode 100644 index 0000000..a0668e8 --- /dev/null +++ b/internal/apply/v1/manager.go @@ -0,0 +1,286 @@ +// 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 v1 + +import ( + "context" + "fmt" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" +) + +// ResourceManagerInterface defines the interface for resource management +type ResourceManagerInterface interface { + // ApplyDeployment executes a deployment plan + 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 + + // 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 + strategyConfig StrategyConfig +} + +// 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 + + // StrategyConfig for deployment strategies + StrategyConfig StrategyConfig +} + +// DefaultResourceManagerOptions returns sensible defaults +func DefaultResourceManagerOptions() ResourceManagerOptions { + return ResourceManagerOptions{ + ParallelLimit: 5, // Conservative parallel limit + RollbackOnFail: true, + OperationTimeout: 2 * time.Minute, + StrategyConfig: DefaultStrategyConfig(), + } +} + +// 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, + strategyConfig: options.StrategyConfig, + } +} + +// 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 + } +} + +// 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 := &ExecutionResult{ + Plan: plan, + CompletedActions: []ActionResult{}, + FailedActions: []ActionResult{}, + Error: fmt.Errorf("prerequisites validation failed: %w", err), + Duration: 0, + } + return result, err + } + + // 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.RollbackPerformed = true + result.RollbackSuccess = true + } + } + + if result != nil && result.Success { + rm.logf("Deployment completed successfully in %v", result.Duration) + } + + return result, err +} + +// 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 { + appKey := edgeconnect.AppKey{ + Organization: plan.AppAction.Desired.Organization, + Name: plan.AppAction.Desired.Name, + Version: plan.AppAction.Desired.Version, + } + + return rm.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region) +} + +// rollbackInstance deletes an instance that was created +func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, action ActionResult, plan *DeploymentPlan) error { + // Find the instance action to get the details + for _, instanceAction := range plan.InstanceActions { + if instanceAction.InstanceName == action.Target { + instanceKey := edgeconnect.AppInstanceKey{ + Organization: plan.AppAction.Desired.Organization, + Name: instanceAction.InstanceName, + CloudletKey: edgeconnect.CloudletKey{ + Organization: instanceAction.Target.CloudletOrg, + Name: instanceAction.Target.CloudletName, + }, + } + return rm.client.DeleteAppInstance(ctx, instanceKey, instanceAction.Target.Region) + } + } + return fmt.Errorf("instance action not found for rollback: %s", action.Target) +} + +// 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...) + } +} diff --git a/internal/apply/v1/manager_test.go b/internal/apply/v1/manager_test.go new file mode 100644 index 0000000..9ed3cac --- /dev/null +++ b/internal/apply/v1/manager_test.go @@ -0,0 +1,497 @@ +// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios +// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients +package v1 + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "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) 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) +} + +// 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{ + 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", + AppVersion: "1.0.0", + Organization: "testorg", + }, + Spec: config.Spec{ + K8sApp: &config.K8sApp{ + ManifestFile: manifestFile, + }, + InfraTemplate: []config.InfraTemplate{ + { + 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", + }, + }, + }, + }, + } +} + +// 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), WithStrategyConfig(createTestStrategyConfig())) + + 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, "test manifest content") + + 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), WithStrategyConfig(createTestStrategyConfig())) + + plan := createTestDeploymentPlan() + config := createTestManagerConfig(t) + + // 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, "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(), "Server error") + + mockClient.AssertExpectations(t) +} + +func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { + mockClient := &MockResourceClient{} + logger := &TestLogger{} + manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig())) + + 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, "test manifest content") + + 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(), "failed to create instance") + + 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, "test manifest content") + + 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), WithStrategyConfig(createTestStrategyConfig())) + + // 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{ + Region: "US", + CloudletOrg: "cloudletorg1", + CloudletName: "cloudlet1", + FlavorName: "small", + }, + Desired: &InstanceState{Name: "instance1"}, + InstanceName: "instance1", + }, + { + Type: ActionCreate, + Target: config.InfraTemplate{ + 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, "test manifest content") + + 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), WithStrategyConfig(createTestStrategyConfig())) + + // 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 TestConvertNetworkRules(t *testing.T) { + 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 := 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) +} diff --git a/internal/apply/v1/planner.go b/internal/apply/v1/planner.go new file mode 100644 index 0000000..33b8d9c --- /dev/null +++ b/internal/apply/v1/planner.go @@ -0,0 +1,555 @@ +// 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 v1 + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "os" + "strings" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" +) + +// EdgeConnectClientInterface defines the methods needed for deployment planning +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 +} + +// 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.Metadata.Name, + Version: config.Metadata.AppVersion, + Organization: config.Metadata.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 + } + + // 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 { + 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" + fmt.Printf("Changes: %v\n", changes) + + 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.Metadata.Name, config.Metadata.AppVersion) + + desired := &InstanceState{ + Name: instanceName, + AppVersion: config.Metadata.AppVersion, + Organization: config.Metadata.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 + } + + // Calculate current manifest hash + hasher := sha256.New() + hasher.Write([]byte(app.DeploymentManifest)) + current.ManifestHash = fmt.Sprintf("%x", hasher.Sum(nil)) + + // 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 + } + + // 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 +} + +// 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)) + } + + // Compare outbound connections + outboundChanges := p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections) + if len(outboundChanges) > 0 { + sb:= strings.Builder{} + sb.WriteString("Outbound connections changed:\n") + for _, change := range outboundChanges { + sb.WriteString(change) + sb.WriteString("\n") + } + changes = append(changes, sb.String()) + } + + return changes, manifestChanged +} + +// compareOutboundConnections compares two sets of outbound connections for equality +func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) []string { + var changes []string + makeMap := func(rules []SecurityRule) map[string]SecurityRule { + m := make(map[string]SecurityRule, 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] = r + } + return m + } + + currentMap := makeMap(current) + desiredMap := makeMap(desired) + + // Find added and modified rules + for key, rule := range desiredMap { + if _, exists := currentMap[key]; !exists { + changes = append(changes, fmt.Sprintf(" - Added outbound connection: %s %d-%d to %s", rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteCIDR)) + } + } + + // Find removed rules + for key, rule := range currentMap { + if _, exists := desiredMap[key]; !exists { + changes = append(changes, fmt.Sprintf(" - Removed outbound connection: %s %d-%d to %s", rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteCIDR)) + } + } + + return changes +} + +// 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) +} diff --git a/internal/apply/v1/planner_test.go b/internal/apply/v1/planner_test.go new file mode 100644 index 0000000..8c1e48a --- /dev/null +++ b/internal/apply/v1/planner_test.go @@ -0,0 +1,663 @@ +// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios +// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios +package v1 + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "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) 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) +} + +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", + AppVersion: "1.0.0", + Organization: "testorg", + }, + Spec: config.Spec{ + K8sApp: &config.K8sApp{ + ManifestFile: manifestFile, + }, + InfraTemplate: []config.InfraTemplate{ + { + Region: "US", + CloudletOrg: "TestCloudletOrg", + CloudletName: "TestCloudlet", + FlavorName: "small", + }, + }, + Network: &config.NetworkConfig{ + OutboundConnections: []config.OutboundConnection{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + }, + }, + }, + } +} + +func TestPlanNewDeployment(t *testing.T) { + mockClient := &MockEdgeConnectClient{} + planner := NewPlanner(mockClient) + testConfig := createTestConfig(t) + + // Mock API calls to return "not found" errors + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("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 and outbound connections + manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" + existingApp := &edgeconnect.App{ + Key: edgeconnect.AppKey{ + Organization: "testorg", + Name: "test-app", + Version: "1.0.0", + }, + Deployment: "kubernetes", + DeploymentManifest: manifestContent, + 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 + } + + // 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 TestPlanWithOptions(t *testing.T) { + mockClient := &MockEdgeConnectClient{} + planner := NewPlanner(mockClient) + testConfig := createTestConfig(t) + + opts := PlanOptions{ + DryRun: true, + SkipStateCheck: true, + Timeout: 10 * time.Second, + } + + ctx := context.Background() + result, err := planner.PlanWithOptions(ctx, testConfig, opts) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Plan) + + plan := result.Plan + assert.True(t, plan.DryRun) + assert.Equal(t, ActionCreate, plan.AppAction.Type) + assert.Contains(t, plan.AppAction.Reason, "state check skipped") + + // No API calls should be made when SkipStateCheck is true + mockClient.AssertNotCalled(t, "ShowApp") + mockClient.AssertNotCalled(t, "ShowAppInstance") +} + +func TestPlanMultipleInfrastructures(t *testing.T) { + mockClient := &MockEdgeConnectClient{} + planner := NewPlanner(mockClient) + testConfig := createTestConfig(t) + + // Add a second infrastructure target + testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{ + Region: "EU", + CloudletOrg: "EUCloudletOrg", + CloudletName: "EUCloudlet", + FlavorName: "medium", + }) + + // Mock API calls to return "not found" errors + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("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 TestCompareAppStatesOutboundConnections(t *testing.T) { + planner := &EdgeConnectPlanner{} + + // Test with no outbound connections + current := &AppState{ + Name: "test-app", + Version: "1.0.0", + AppType: AppTypeK8s, + OutboundConnections: nil, + } + + desired := &AppState{ + Name: "test-app", + Version: "1.0.0", + AppType: AppTypeK8s, + OutboundConnections: nil, + } + + changes, _ := planner.compareAppStates(current, desired) + assert.Empty(t, changes, "No changes expected when both have no outbound connections") + + // Test adding outbound connections + desired.OutboundConnections = []SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + } + + changes, _ = planner.compareAppStates(current, desired) + assert.Len(t, changes, 1) + assert.Contains(t, changes[0], "Outbound connections changed") + + // Test identical outbound connections + current.OutboundConnections = []SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + } + + changes, _ = planner.compareAppStates(current, desired) + assert.Empty(t, changes, "No changes expected when outbound connections are identical") + + // Test different outbound connections (different port) + desired.OutboundConnections[0].PortRangeMin = 443 + desired.OutboundConnections[0].PortRangeMax = 443 + + changes, _ = planner.compareAppStates(current, desired) + assert.Len(t, changes, 1) + assert.Contains(t, changes[0], "Outbound connections changed") + + // Test same connections but different order (should be considered equal) + current.OutboundConnections = []SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + { + Protocol: "tcp", + PortRangeMin: 443, + PortRangeMax: 443, + RemoteCIDR: "0.0.0.0/0", + }, + } + + desired.OutboundConnections = []SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 443, + PortRangeMax: 443, + RemoteCIDR: "0.0.0.0/0", + }, + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + } + + changes, _ = planner.compareAppStates(current, desired) + assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order") + + // Test removing outbound connections + desired.OutboundConnections = nil + + changes, _ = planner.compareAppStates(current, desired) + assert.Len(t, changes, 1) + assert.Contains(t, changes[0], "Outbound connections changed") +} + +func TestCompareInstanceStates(t *testing.T) { + planner := &EdgeConnectPlanner{} + + current := &InstanceState{ + Name: "test-instance", + FlavorName: "small", + CloudletName: "oldcloudlet", + CloudletOrg: "oldorg", + } + + desired := &InstanceState{ + Name: "test-instance", + FlavorName: "medium", + CloudletName: "newcloudlet", + CloudletOrg: "neworg", + } + + changes := planner.compareInstanceStates(current, desired) + assert.Len(t, changes, 3) + assert.Contains(t, changes[0], "Flavor changed") + assert.Contains(t, changes[1], "Cloudlet changed") + assert.Contains(t, changes[2], "Cloudlet org changed") + + // Test no changes + desired.FlavorName = "small" + desired.CloudletName = "oldcloudlet" + desired.CloudletOrg = "oldorg" + changes = planner.compareInstanceStates(current, desired) + assert.Empty(t, changes) +} + +func TestDeploymentPlanMethods(t *testing.T) { + plan := &DeploymentPlan{ + ConfigName: "test-plan", + AppAction: AppAction{ + Type: ActionCreate, + Desired: &AppState{Name: "test-app"}, + }, + InstanceActions: []InstanceAction{ + { + Type: ActionCreate, + Target: config.InfraTemplate{ + CloudletOrg: "org1", + CloudletName: "cloudlet1", + Region: "US", + }, + InstanceName: "instance1", + Desired: &InstanceState{Name: "instance1"}, + }, + { + Type: ActionUpdate, + Target: config.InfraTemplate{ + CloudletOrg: "org2", + CloudletName: "cloudlet2", + Region: "EU", + }, + InstanceName: "instance2", + Desired: &InstanceState{Name: "instance2"}, + }, + }, + } + + // Test IsEmpty + assert.False(t, plan.IsEmpty()) + + // Test GetTargetCloudlets + cloudlets := plan.GetTargetCloudlets() + assert.Len(t, cloudlets, 2) + assert.Contains(t, cloudlets, "org1:cloudlet1") + assert.Contains(t, cloudlets, "org2:cloudlet2") + + // Test GetTargetRegions + regions := plan.GetTargetRegions() + assert.Len(t, regions, 2) + assert.Contains(t, regions, "US") + assert.Contains(t, regions, "EU") + + // Test GenerateSummary + summary := plan.GenerateSummary() + assert.Contains(t, summary, "test-plan") + assert.Contains(t, summary, "CREATE application") + assert.Contains(t, summary, "CREATE 1 instance") + assert.Contains(t, summary, "UPDATE 1 instance") + + // Test Validate + err := plan.Validate() + assert.NoError(t, err) + + // Test validation failure + plan.AppAction.Desired = nil + err = plan.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "must have desired state") +} + +func TestEstimateDeploymentDuration(t *testing.T) { + planner := &EdgeConnectPlanner{} + + plan := &DeploymentPlan{ + AppAction: AppAction{Type: ActionCreate}, + InstanceActions: []InstanceAction{ + {Type: ActionCreate}, + {Type: ActionUpdate}, + }, + } + + duration := planner.estimateDeploymentDuration(plan) + assert.Greater(t, duration, time.Duration(0)) + assert.Less(t, duration, 10*time.Minute) // Reasonable upper bound + + // Test with no actions + emptyPlan := &DeploymentPlan{ + AppAction: AppAction{Type: ActionNone}, + InstanceActions: []InstanceAction{}, + } + + emptyDuration := planner.estimateDeploymentDuration(emptyPlan) + assert.Greater(t, emptyDuration, time.Duration(0)) + assert.Less(t, emptyDuration, duration) // Should be less than plan with actions +} + +func TestIsResourceNotFoundError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + {"nil error", nil, false}, + {"not found error", &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) +} diff --git a/internal/apply/strategy.go b/internal/apply/v1/strategy.go similarity index 99% rename from internal/apply/strategy.go rename to internal/apply/v1/strategy.go index 8d32d2e..44f2471 100644 --- a/internal/apply/strategy.go +++ b/internal/apply/v1/strategy.go @@ -1,6 +1,6 @@ // ABOUTME: Deployment strategy framework for EdgeConnect apply command // ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling) -package apply +package v1 import ( "context" diff --git a/internal/apply/v1/strategy_recreate.go b/internal/apply/v1/strategy_recreate.go new file mode 100644 index 0000000..1f6f121 --- /dev/null +++ b/internal/apply/v1/strategy_recreate.go @@ -0,0 +1,548 @@ +// ABOUTME: Recreate deployment strategy implementation for EdgeConnect +// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution +package v1 + +import ( + "context" + "errors" + "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 + + // Check if error is retryable (don't retry 4xx client errors) + if !isRetryableError(err) { + r.logf("Failed to %s instance %s: %v (non-retryable error, giving up)", operation, action.InstanceName, err) + result.Error = fmt.Errorf("non-retryable error: %w", err) + result.Duration = time.Since(startTime) + return result + } + + 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 + + // Check if error is retryable (don't retry 4xx client errors) + if !isRetryableError(err) { + r.logf("Failed to update app: %v (non-retryable error, giving up)", err) + result.Error = fmt.Errorf("non-retryable error: %w", err) + result.Duration = time.Since(startTime) + return result + } + + 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.Desired.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.Desired.Organization, + Name: action.InstanceName, + CloudletKey: edgeconnect.CloudletKey{ + Organization: action.Target.CloudletOrg, + Name: action.Target.CloudletName, + }, + }, + AppKey: edgeconnect.AppKey{ + Organization: action.Desired.Organization, + Name: config.Metadata.Name, + Version: config.Metadata.AppVersion, + }, + 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...) + } +} + +// isRetryableError determines if an error should be retried +// Returns false for client errors (4xx), true for server errors (5xx) and other transient errors +func isRetryableError(err error) bool { + if err == nil { + return false + } + + // Check if it's an APIError with a status code + var apiErr *edgeconnect.APIError + if errors.As(err, &apiErr) { + // Don't retry client errors (4xx) + if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { + return false + } + // Retry server errors (5xx) + if apiErr.StatusCode >= 500 { + return true + } + } + + // Retry all other errors (network issues, timeouts, etc.) + return true +} diff --git a/internal/apply/v1/types.go b/internal/apply/v1/types.go new file mode 100644 index 0000000..223fa74 --- /dev/null +++ b/internal/apply/v1/types.go @@ -0,0 +1,462 @@ +// ABOUTME: Deployment planning types for EdgeConnect apply command with state management +// ABOUTME: Defines structures for deployment plans, actions, and state comparison results +package v1 + +import ( + "fmt" + "strings" + "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 + +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 + + // OutboundConnections contains the required outbound network connections + OutboundConnections []SecurityRule +} + +// 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" + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName)) + + // App actions + if dp.AppAction.Type != ActionNone { + sb.WriteString(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 { + sb.WriteString(fmt.Sprintf(" - %s\n", change)) + } + } + } + + // Instance actions + createCount := 0 + updateActions := []InstanceAction{} + for _, action := range dp.InstanceActions { + switch action.Type { + case ActionCreate: + createCount++ + case ActionUpdate: + updateActions = append(updateActions, action) + } + } + + if createCount > 0 { + sb.WriteString(fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets()))) + } + + if len(updateActions) > 0 { + sb.WriteString(fmt.Sprintf("- UPDATE %d instance(s)\n", len(updateActions))) + for _, action := range updateActions { + if len(action.Changes) > 0 { + sb.WriteString(fmt.Sprintf(" - Instance '%s':\n", action.InstanceName)) + for _, change := range action.Changes { + sb.WriteString(fmt.Sprintf(" - %s\n", change)) + } + } + } + } + + sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String())) + + return sb.String() +} + +// 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 +} + +// 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/apply/manager.go b/internal/apply/v2/manager.go similarity index 99% rename from internal/apply/manager.go rename to internal/apply/v2/manager.go index 3e6d837..fc1b483 100644 --- a/internal/apply/manager.go +++ b/internal/apply/v2/manager.go @@ -1,6 +1,6 @@ // 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 +package v2 import ( "context" diff --git a/internal/apply/manager_test.go b/internal/apply/v2/manager_test.go similarity index 99% rename from internal/apply/manager_test.go rename to internal/apply/v2/manager_test.go index f2135b5..68c60fd 100644 --- a/internal/apply/manager_test.go +++ b/internal/apply/v2/manager_test.go @@ -1,6 +1,6 @@ // ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios // ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients -package apply +package v2 import ( "context" diff --git a/internal/apply/planner.go b/internal/apply/v2/planner.go similarity index 99% rename from internal/apply/planner.go rename to internal/apply/v2/planner.go index d4f3e82..52de1ee 100644 --- a/internal/apply/planner.go +++ b/internal/apply/v2/planner.go @@ -1,6 +1,6 @@ // 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 +package v2 import ( "context" diff --git a/internal/apply/planner_test.go b/internal/apply/v2/planner_test.go similarity index 99% rename from internal/apply/planner_test.go rename to internal/apply/v2/planner_test.go index 6f7c39b..fe56871 100644 --- a/internal/apply/planner_test.go +++ b/internal/apply/v2/planner_test.go @@ -1,6 +1,6 @@ // ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios // ABOUTME: Tests planning logic, state comparison, and various deployment scenarios -package apply +package v2 import ( "context" diff --git a/internal/apply/v2/strategy.go b/internal/apply/v2/strategy.go new file mode 100644 index 0000000..6a1661a --- /dev/null +++ b/internal/apply/v2/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 v2 + +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/v2/strategy_recreate.go similarity index 99% rename from internal/apply/strategy_recreate.go rename to internal/apply/v2/strategy_recreate.go index dc44784..739a454 100644 --- a/internal/apply/strategy_recreate.go +++ b/internal/apply/v2/strategy_recreate.go @@ -1,6 +1,6 @@ // ABOUTME: Recreate deployment strategy implementation for EdgeConnect // ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution -package apply +package v2 import ( "context" diff --git a/internal/apply/types.go b/internal/apply/v2/types.go similarity index 99% rename from internal/apply/types.go rename to internal/apply/v2/types.go index 279832a..90b7956 100644 --- a/internal/apply/types.go +++ b/internal/apply/v2/types.go @@ -1,6 +1,6 @@ // ABOUTME: Deployment planning types for EdgeConnect apply command with state management // ABOUTME: Defines structures for deployment plans, actions, and state comparison results -package apply +package v2 import ( "fmt"