feat(sdk, cli): Implemented update endpoints. Added recreate deployment strategy to cli. Fixed tests.

This commit is contained in:
Waldemar 2025-10-01 10:49:15 +02:00
parent 240a9028b3
commit 7bfdeba49f
13 changed files with 1092 additions and 791 deletions

View file

@ -5,9 +5,6 @@ package apply
import (
"context"
"fmt"
"io"
"os"
"sync"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
@ -17,7 +14,7 @@ import (
// ResourceManagerInterface defines the interface for resource management
type ResourceManagerInterface interface {
// ApplyDeployment executes a deployment plan
ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig) (*ExecutionResult, error)
ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error)
// RollbackDeployment attempts to rollback a failed deployment
RollbackDeployment(ctx context.Context, result *ExecutionResult) error
@ -32,6 +29,7 @@ type EdgeConnectResourceManager struct {
parallelLimit int
rollbackOnFail bool
logger Logger
strategyConfig StrategyConfig
}
// Logger interface for deployment logging
@ -52,6 +50,9 @@ type ResourceManagerOptions struct {
// Timeout for individual operations
OperationTimeout time.Duration
// StrategyConfig for deployment strategies
StrategyConfig StrategyConfig
}
// DefaultResourceManagerOptions returns sensible defaults
@ -60,6 +61,7 @@ func DefaultResourceManagerOptions() ResourceManagerOptions {
ParallelLimit: 5, // Conservative parallel limit
RollbackOnFail: true,
OperationTimeout: 2 * time.Minute,
StrategyConfig: DefaultStrategyConfig(),
}
}
@ -75,6 +77,7 @@ func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*Resourc
parallelLimit: options.ParallelLimit,
rollbackOnFail: options.RollbackOnFail,
logger: options.Logger,
strategyConfig: options.StrategyConfig,
}
}
@ -99,321 +102,82 @@ func WithLogger(logger Logger) func(*ResourceManagerOptions) {
}
}
// ApplyDeployment executes a deployment plan with rollback support
func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig) (*ExecutionResult, error) {
startTime := time.Now()
rm.logf("Starting deployment: %s", plan.ConfigName)
result := &ExecutionResult{
Plan: plan,
CompletedActions: []ActionResult{},
FailedActions: []ActionResult{},
// WithStrategyConfig sets the strategy configuration
func WithStrategyConfig(config StrategyConfig) func(*ResourceManagerOptions) {
return func(opts *ResourceManagerOptions) {
opts.StrategyConfig = config
}
}
// ApplyDeployment executes a deployment plan using deployment strategies
func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) {
rm.logf("Starting deployment: %s", plan.ConfigName)
// Step 1: Validate prerequisites
if err := rm.ValidatePrerequisites(ctx, plan); err != nil {
result.Error = fmt.Errorf("prerequisites validation failed: %w", err)
result.Duration = time.Since(startTime)
result := &ExecutionResult{
Plan: plan,
CompletedActions: []ActionResult{},
FailedActions: []ActionResult{},
Error: fmt.Errorf("prerequisites validation failed: %w", err),
Duration: 0,
}
return result, err
}
// Step 2: Execute app action first (apps must exist before instances)
if plan.AppAction.Type != ActionNone {
appResult := rm.executeAppAction(ctx, plan.AppAction, config)
if appResult.Success {
result.CompletedActions = append(result.CompletedActions, appResult)
rm.logf("App action completed: %s", appResult.Type)
// Step 2: Determine deployment strategy
strategyName := DeploymentStrategy(config.Spec.GetDeploymentStrategy())
rm.logf("Using deployment strategy: %s", strategyName)
// Step 3: Create strategy executor
strategyConfig := rm.strategyConfig
strategyConfig.ParallelOperations = rm.parallelLimit > 1
factory := NewStrategyFactory(rm.client, strategyConfig, rm.logger)
strategy, err := factory.CreateStrategy(strategyName)
if err != nil {
result := &ExecutionResult{
Plan: plan,
CompletedActions: []ActionResult{},
FailedActions: []ActionResult{},
Error: fmt.Errorf("failed to create deployment strategy: %w", err),
Duration: 0,
}
return result, err
}
// Step 4: Validate strategy can handle this deployment
if err := strategy.Validate(plan); err != nil {
result := &ExecutionResult{
Plan: plan,
CompletedActions: []ActionResult{},
FailedActions: []ActionResult{},
Error: fmt.Errorf("strategy validation failed: %w", err),
Duration: 0,
}
return result, err
}
// Step 5: Execute the deployment strategy
rm.logf("Estimated deployment duration: %v", strategy.EstimateDuration(plan))
result, err := strategy.Execute(ctx, plan, config, manifestContent)
// Step 6: Handle rollback if needed
if err != nil && rm.rollbackOnFail && result != nil {
rm.logf("Deployment failed, attempting rollback...")
if rollbackErr := rm.RollbackDeployment(ctx, result); rollbackErr != nil {
rm.logf("Rollback failed: %v", rollbackErr)
} else {
result.FailedActions = append(result.FailedActions, appResult)
rm.logf("App action failed: %s - %v", appResult.Type, appResult.Error)
if rm.rollbackOnFail {
rm.logf("Attempting rollback...")
if rollbackErr := rm.RollbackDeployment(ctx, result); rollbackErr != nil {
rm.logf("Rollback failed: %v", rollbackErr)
} else {
result.RollbackPerformed = true
result.RollbackSuccess = true
}
}
result.Error = appResult.Error
result.Duration = time.Since(startTime)
return result, appResult.Error
result.RollbackPerformed = true
result.RollbackSuccess = true
}
}
// Step 3: Execute instance actions in parallel
instanceResults := rm.executeInstanceActions(ctx, plan.InstanceActions, config)
for _, instanceResult := range instanceResults {
if instanceResult.Success {
result.CompletedActions = append(result.CompletedActions, instanceResult)
} else {
result.FailedActions = append(result.FailedActions, instanceResult)
}
}
// Check if deployment succeeded
result.Success = len(result.FailedActions) == 0
result.Duration = time.Since(startTime)
if !result.Success {
result.Error = fmt.Errorf("%d instance actions failed", len(result.FailedActions))
if rm.rollbackOnFail {
rm.logf("Deployment failed, attempting rollback...")
if rollbackErr := rm.RollbackDeployment(ctx, result); rollbackErr != nil {
rm.logf("Rollback failed: %v", rollbackErr)
} else {
result.RollbackPerformed = true
result.RollbackSuccess = true
}
}
} else {
if result != nil && result.Success {
rm.logf("Deployment completed successfully in %v", result.Duration)
}
return result, result.Error
}
// executeAppAction handles application creation/update operations
func (rm *EdgeConnectResourceManager) executeAppAction(ctx context.Context, action AppAction, config *config.EdgeConnectConfig) ActionResult {
startTime := time.Now()
result := ActionResult{
Type: action.Type,
Target: action.Desired.Name,
}
switch action.Type {
case ActionCreate:
result.Success, result.Error = rm.createApplication(ctx, action, config)
result.Details = fmt.Sprintf("Created application %s version %s", action.Desired.Name, action.Desired.Version)
case ActionUpdate:
result.Success, result.Error = rm.updateApplication(ctx, action, config)
result.Details = fmt.Sprintf("Updated application %s version %s", action.Desired.Name, action.Desired.Version)
default:
result.Success = true
result.Details = "No action required"
}
result.Duration = time.Since(startTime)
return result
}
// executeInstanceActions handles instance deployment across multiple cloudlets in parallel
func (rm *EdgeConnectResourceManager) executeInstanceActions(ctx context.Context, actions []InstanceAction, config *config.EdgeConnectConfig) []ActionResult {
if len(actions) == 0 {
return []ActionResult{}
}
// Create semaphore to limit parallel operations
semaphore := make(chan struct{}, rm.parallelLimit)
results := make([]ActionResult, len(actions))
var wg sync.WaitGroup
for i, action := range actions {
if action.Type == ActionNone {
results[i] = ActionResult{
Type: action.Type,
Target: action.InstanceName,
Success: true,
Details: "No action required",
}
continue
}
wg.Add(1)
go func(index int, instanceAction InstanceAction) {
defer wg.Done()
// Acquire semaphore
semaphore <- struct{}{}
defer func() { <-semaphore }()
results[index] = rm.executeInstanceAction(ctx, instanceAction, config)
}(i, action)
}
wg.Wait()
return results
}
// executeInstanceAction handles single instance operations
func (rm *EdgeConnectResourceManager) executeInstanceAction(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) ActionResult {
startTime := time.Now()
result := ActionResult{
Type: action.Type,
Target: action.InstanceName,
}
switch action.Type {
case ActionCreate:
result.Success, result.Error = rm.createInstance(ctx, action, config)
result.Details = fmt.Sprintf("Created instance %s on %s:%s",
action.InstanceName, action.Target.CloudletOrg, action.Target.CloudletName)
case ActionUpdate:
result.Success, result.Error = rm.updateInstance(ctx, action, config)
result.Details = fmt.Sprintf("Updated instance %s", action.InstanceName)
default:
result.Success = true
result.Details = "No action required"
}
result.Duration = time.Since(startTime)
return result
}
// createApplication creates a new application with manifest file processing
func (rm *EdgeConnectResourceManager) createApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig) (bool, error) {
// Read and process manifest file
manifestContent, err := rm.readManifestFile(config.Spec.GetManifestFile())
if err != nil {
return false, fmt.Errorf("failed to read manifest file: %w", err)
}
// Build the app input
appInput := &edgeconnect.NewAppInput{
Region: action.Desired.Region,
App: edgeconnect.App{
Key: edgeconnect.AppKey{
Organization: action.Desired.Organization,
Name: action.Desired.Name,
Version: action.Desired.Version,
},
Deployment: rm.getDeploymentType(config),
ImageType: "ImageTypeDocker", // Default for EdgeConnect
ImagePath: rm.getImagePath(config),
AllowServerless: true, // Required for Kubernetes
DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
ServerlessConfig: struct{}{}, // Required empty struct
DeploymentManifest: manifestContent,
DeploymentGenerator: "kubernetes-basic",
},
}
// Add network configuration if specified
if config.Spec.Network != nil {
appInput.App.RequiredOutboundConnections = rm.convertNetworkRules(config.Spec.Network)
}
// Create the application
if err := rm.client.CreateApp(ctx, appInput); err != nil {
return false, fmt.Errorf("failed to create application: %w", err)
}
rm.logf("Successfully created application: %s/%s version %s",
action.Desired.Organization, action.Desired.Name, action.Desired.Version)
return true, nil
}
// updateApplication updates an existing application
func (rm *EdgeConnectResourceManager) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig) (bool, error) {
// For now, EdgeConnect doesn't support app updates directly
// This would be implemented when the API supports app updates
rm.logf("Application update not yet supported by EdgeConnect API")
return true, nil
}
// createInstance creates a new application instance
func (rm *EdgeConnectResourceManager) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
instanceInput := &edgeconnect.NewAppInstanceInput{
Region: action.Target.Region,
AppInst: edgeconnect.AppInstance{
Key: edgeconnect.AppInstanceKey{
Organization: action.Target.Organization,
Name: action.InstanceName,
CloudletKey: edgeconnect.CloudletKey{
Organization: action.Target.CloudletOrg,
Name: action.Target.CloudletName,
},
},
AppKey: edgeconnect.AppKey{
Organization: action.Target.Organization,
Name: config.Metadata.Name,
Version: config.Spec.GetAppVersion(),
},
Flavor: edgeconnect.Flavor{
Name: action.Target.FlavorName,
},
},
}
// Create the instance
if err := rm.client.CreateAppInstance(ctx, instanceInput); err != nil {
return false, fmt.Errorf("failed to create instance: %w", err)
}
rm.logf("Successfully created instance: %s on %s:%s",
action.InstanceName, action.Target.CloudletOrg, action.Target.CloudletName)
return true, nil
}
// updateInstance updates an existing application instance
func (rm *EdgeConnectResourceManager) updateInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
// For now, instance updates would require delete/recreate
// This would be optimized when the API supports direct instance updates
rm.logf("Instance update requires recreate - not yet optimized")
return true, nil
}
// readManifestFile reads and returns the contents of a manifest file
func (rm *EdgeConnectResourceManager) readManifestFile(manifestPath string) (string, error) {
if manifestPath == "" {
return "", nil
}
file, err := os.Open(manifestPath)
if err != nil {
return "", fmt.Errorf("failed to open manifest file %s: %w", manifestPath, err)
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("failed to read manifest file %s: %w", manifestPath, err)
}
return string(content), nil
}
// getDeploymentType determines the deployment type from config
func (rm *EdgeConnectResourceManager) getDeploymentType(config *config.EdgeConnectConfig) string {
if config.Spec.IsK8sApp() {
return "kubernetes"
}
return "docker"
}
// getImagePath gets the image path for the application
func (rm *EdgeConnectResourceManager) getImagePath(config *config.EdgeConnectConfig) string {
if config.Spec.IsDockerApp() && config.Spec.DockerApp.Image != "" {
return config.Spec.DockerApp.Image
}
// Default for kubernetes apps
return "https://registry-1.docker.io/library/nginx:latest"
}
// convertNetworkRules converts config network rules to EdgeConnect SecurityRules
func (rm *EdgeConnectResourceManager) convertNetworkRules(network *config.NetworkConfig) []edgeconnect.SecurityRule {
rules := make([]edgeconnect.SecurityRule, len(network.OutboundConnections))
for i, conn := range network.OutboundConnections {
rules[i] = edgeconnect.SecurityRule{
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
}
return rules
return result, err
}
// ValidatePrerequisites checks if deployment prerequisites are met

View file

@ -37,6 +37,16 @@ func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey edgeconnect.A
return args.Error(0)
}
func (m *MockResourceClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error {
args := m.Called(ctx, instanceKey, region)
return args.Error(0)
@ -157,10 +167,20 @@ func createTestManagerConfig(t *testing.T) *config.EdgeConnectConfig {
}
}
// createTestStrategyConfig returns a fast configuration for tests
func createTestStrategyConfig() StrategyConfig {
return StrategyConfig{
MaxRetries: 0, // No retries for fast tests
HealthCheckTimeout: 1 * time.Millisecond,
ParallelOperations: false, // Sequential for predictable tests
RetryDelay: 0, // No delay
}
}
func TestApplyDeploymentSuccess(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger))
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan()
config := createTestManagerConfig(t)
@ -172,7 +192,7 @@ func TestApplyDeploymentSuccess(t *testing.T) {
Return(nil)
ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config)
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
require.NoError(t, err)
require.NotNil(t, result)
@ -191,24 +211,24 @@ func TestApplyDeploymentSuccess(t *testing.T) {
func TestApplyDeploymentAppFailure(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger))
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan()
config := createTestManagerConfig(t)
// Mock app creation failure
// Mock app creation failure - deployment should stop here
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")).
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}})
ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config)
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
require.Error(t, err)
require.NotNil(t, result)
assert.False(t, result.Success)
assert.Len(t, result.CompletedActions, 0)
assert.Len(t, result.FailedActions, 1)
assert.Contains(t, err.Error(), "failed to create application")
assert.Contains(t, err.Error(), "Server error")
mockClient.AssertExpectations(t)
}
@ -216,7 +236,7 @@ func TestApplyDeploymentAppFailure(t *testing.T) {
func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true))
manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan()
config := createTestManagerConfig(t)
@ -232,7 +252,7 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
Return(nil)
ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config)
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
require.Error(t, err)
require.NotNil(t, result)
@ -241,7 +261,7 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
assert.Len(t, result.FailedActions, 1) // Instance failed
assert.True(t, result.RollbackPerformed)
assert.True(t, result.RollbackSuccess)
assert.Contains(t, err.Error(), "instance actions failed")
assert.Contains(t, err.Error(), "failed to create instance")
mockClient.AssertExpectations(t)
}
@ -258,7 +278,7 @@ func TestApplyDeploymentNoActions(t *testing.T) {
config := createTestManagerConfig(t)
ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config)
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
require.Error(t, err)
require.NotNil(t, result)
@ -271,7 +291,7 @@ func TestApplyDeploymentNoActions(t *testing.T) {
func TestApplyDeploymentMultipleInstances(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2))
manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig()))
// Create plan with multiple instances
plan := &DeploymentPlan{
@ -322,7 +342,7 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
Return(nil)
ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config)
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
require.NoError(t, err)
require.NotNil(t, result)
@ -382,7 +402,7 @@ func TestValidatePrerequisites(t *testing.T) {
func TestRollbackDeployment(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger))
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
// Create result with completed actions
plan := createTestDeploymentPlan()
@ -447,76 +467,7 @@ func TestRollbackDeploymentFailure(t *testing.T) {
mockClient.AssertExpectations(t)
}
func TestReadManifestFile(t *testing.T) {
manager := &EdgeConnectResourceManager{}
tempDir := t.TempDir()
// Create test file
testFile := filepath.Join(tempDir, "test.yaml")
expectedContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
err := os.WriteFile(testFile, []byte(expectedContent), 0644)
require.NoError(t, err)
content, err := manager.readManifestFile(testFile)
require.NoError(t, err)
assert.Equal(t, expectedContent, content)
// Test empty path
content, err = manager.readManifestFile("")
require.NoError(t, err)
assert.Empty(t, content)
// Test non-existent file
_, err = manager.readManifestFile("/non/existent/file")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to open manifest file")
}
func TestGetDeploymentType(t *testing.T) {
manager := &EdgeConnectResourceManager{}
// Test k8s app
k8sConfig := &config.EdgeConnectConfig{
Spec: config.Spec{
K8sApp: &config.K8sApp{},
},
}
assert.Equal(t, "kubernetes", manager.getDeploymentType(k8sConfig))
// Test docker app
dockerConfig := &config.EdgeConnectConfig{
Spec: config.Spec{
DockerApp: &config.DockerApp{},
},
}
assert.Equal(t, "docker", manager.getDeploymentType(dockerConfig))
}
func TestGetImagePath(t *testing.T) {
manager := &EdgeConnectResourceManager{}
// Test docker app with image
dockerConfig := &config.EdgeConnectConfig{
Spec: config.Spec{
DockerApp: &config.DockerApp{
Image: "my-custom-image:latest",
},
},
}
assert.Equal(t, "my-custom-image:latest", manager.getImagePath(dockerConfig))
// Test k8s app (should use default)
k8sConfig := &config.EdgeConnectConfig{
Spec: config.Spec{
K8sApp: &config.K8sApp{},
},
}
assert.Equal(t, "https://registry-1.docker.io/library/nginx:latest", manager.getImagePath(k8sConfig))
}
func TestConvertNetworkRules(t *testing.T) {
manager := &EdgeConnectResourceManager{}
network := &config.NetworkConfig{
OutboundConnections: []config.OutboundConnection{
{
@ -534,7 +485,7 @@ func TestConvertNetworkRules(t *testing.T) {
},
}
rules := manager.convertNetworkRules(network)
rules := convertNetworkRules(network)
require.Len(t, rules, 2)
assert.Equal(t, "tcp", rules[0].Protocol)
@ -547,47 +498,3 @@ func TestConvertNetworkRules(t *testing.T) {
assert.Equal(t, 443, rules[1].PortRangeMax)
assert.Equal(t, "10.0.0.0/8", rules[1].RemoteCIDR)
}
func TestCreateApplicationInput(t *testing.T) {
mockClient := &MockResourceClient{}
manager := NewResourceManager(mockClient)
config := createTestManagerConfig(t)
action := AppAction{
Type: ActionCreate,
Desired: &AppState{
Name: "test-app",
Version: "1.0.0",
Organization: "testorg",
Region: "US",
},
}
// Capture the input passed to CreateApp
var capturedInput *edgeconnect.NewAppInput
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")).
Run(func(args mock.Arguments) {
capturedInput = args.Get(1).(*edgeconnect.NewAppInput)
}).
Return(nil)
ctx := context.Background()
success, err := manager.(*EdgeConnectResourceManager).createApplication(ctx, action, config)
require.NoError(t, err)
assert.True(t, success)
require.NotNil(t, capturedInput)
// Verify the input was constructed correctly
assert.Equal(t, "US", capturedInput.Region)
assert.Equal(t, "testorg", capturedInput.App.Key.Organization)
assert.Equal(t, "test-app", capturedInput.App.Key.Name)
assert.Equal(t, "1.0.0", capturedInput.App.Key.Version)
assert.Equal(t, "kubernetes", capturedInput.App.Deployment)
assert.Equal(t, "ImageTypeDocker", capturedInput.App.ImageType)
assert.True(t, capturedInput.App.AllowServerless)
assert.NotEmpty(t, capturedInput.App.DeploymentManifest)
assert.Len(t, capturedInput.App.RequiredOutboundConnections, 1)
mockClient.AssertExpectations(t)
}

View file

@ -19,9 +19,11 @@ import (
type EdgeConnectClientInterface interface {
ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error)
CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error
UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error
DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error
ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error)
CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error
UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error
}
@ -144,6 +146,19 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E
desired.AppType = AppTypeDocker
}
// Extract outbound connections from config
if config.Spec.Network != nil {
desired.OutboundConnections = make([]SecurityRule, len(config.Spec.Network.OutboundConnections))
for i, conn := range config.Spec.Network.OutboundConnections {
desired.OutboundConnections[i] = SecurityRule{
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
}
}
// Calculate manifest hash
manifestHash, err := p.calculateManifestHash(config.Spec.GetManifestFile())
if err != nil {
@ -299,6 +314,17 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
current.AppType = AppTypeDocker
}
// Extract outbound connections from the app
current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections))
for i, conn := range app.RequiredOutboundConnections {
current.OutboundConnections[i] = SecurityRule{
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
}
return current, nil
}
@ -357,9 +383,46 @@ func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]str
changes = append(changes, fmt.Sprintf("App type changed: %s -> %s", current.AppType, desired.AppType))
}
// Compare outbound connections
if !p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections) {
changes = append(changes, "Outbound connections changed")
}
return changes, manifestChanged
}
// compareOutboundConnections compares two sets of outbound connections for equality
func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) bool {
makeMap := func(rules []SecurityRule) map[string]struct{} {
m := make(map[string]struct{}, len(rules))
for _, r := range rules {
key := fmt.Sprintf("%s:%d-%d:%s",
strings.ToLower(r.Protocol),
r.PortRangeMin,
r.PortRangeMax,
r.RemoteCIDR,
)
m[key] = struct{}{}
}
return m
}
currentMap := makeMap(current)
desiredMap := makeMap(desired)
if len(currentMap) != len(desiredMap) {
return false
}
for k := range currentMap {
if _, exists := desiredMap[k]; !exists {
return false
}
}
return true
}
// compareInstanceStates compares current and desired instance states and returns changes
func (p *EdgeConnectPlanner) compareInstanceStates(current, desired *InstanceState) []string {
var changes []string

View file

@ -52,6 +52,16 @@ func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey edgeconnec
return args.Error(0)
}
func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error {
args := m.Called(ctx, instanceKey, region)
return args.Error(0)
@ -174,7 +184,7 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) {
// Note: We would calculate expected manifest hash here when API supports it
// Mock existing app with same manifest hash
// Mock existing app with same manifest hash and outbound connections
existingApp := &edgeconnect.App{
Key: edgeconnect.AppKey{
Organization: "testorg",
@ -182,6 +192,14 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) {
Version: "1.0.0",
},
Deployment: "kubernetes",
RequiredOutboundConnections: []edgeconnect.SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
// Note: Manifest hash tracking would be implemented when API supports annotations
}
@ -231,12 +249,6 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) {
mockClient.AssertExpectations(t)
}
func TestPlanManifestChanged(t *testing.T) {
// Skip this test for now since manifest hash comparison isn't implemented yet
// due to EdgeConnect API not supporting annotations
t.Skip("Manifest hash comparison not implemented - waiting for API support for annotations")
}
func TestPlanWithOptions(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
@ -389,6 +401,104 @@ func TestCompareAppStates(t *testing.T) {
assert.Contains(t, changes[0], "App type changed")
}
func TestCompareAppStatesOutboundConnections(t *testing.T) {
planner := &EdgeConnectPlanner{}
// Test with no outbound connections
current := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
OutboundConnections: nil,
}
desired := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
OutboundConnections: nil,
}
changes, _ := planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when both have no outbound connections")
// Test adding outbound connections
desired.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
// Test identical outbound connections
current.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when outbound connections are identical")
// Test different outbound connections (different port)
desired.OutboundConnections[0].PortRangeMin = 443
desired.OutboundConnections[0].PortRangeMax = 443
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
// Test same connections but different order (should be considered equal)
current.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 443,
PortRangeMax: 443,
RemoteCIDR: "0.0.0.0/0",
},
}
desired.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 443,
PortRangeMax: 443,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order")
// Test removing outbound connections
desired.OutboundConnections = nil
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
}
func TestCompareInstanceStates(t *testing.T) {
planner := &EdgeConnectPlanner{}

106
internal/apply/strategy.go Normal file
View file

@ -0,0 +1,106 @@
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
package apply
import (
"context"
"fmt"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
)
// DeploymentStrategy represents the type of deployment strategy
type DeploymentStrategy string
const (
// StrategyRecreate deletes all instances, updates app, then creates new instances
StrategyRecreate DeploymentStrategy = "recreate"
// StrategyBlueGreen creates new instances alongside old ones, then switches traffic (future)
StrategyBlueGreen DeploymentStrategy = "blue-green"
// StrategyRolling updates instances one by one with health checks (future)
StrategyRolling DeploymentStrategy = "rolling"
)
// DeploymentStrategyExecutor defines the interface that all deployment strategies must implement
type DeploymentStrategyExecutor interface {
// Execute runs the deployment strategy
Execute(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error)
// Validate checks if the strategy can be used for this deployment
Validate(plan *DeploymentPlan) error
// EstimateDuration provides time estimate for this strategy
EstimateDuration(plan *DeploymentPlan) time.Duration
// GetName returns the strategy name
GetName() DeploymentStrategy
}
// StrategyConfig holds configuration for deployment strategies
type StrategyConfig struct {
// MaxRetries is the number of times to retry failed operations
MaxRetries int
// HealthCheckTimeout is the maximum time to wait for health checks
HealthCheckTimeout time.Duration
// ParallelOperations enables parallel execution of operations
ParallelOperations bool
// RetryDelay is the delay between retry attempts
RetryDelay time.Duration
}
// DefaultStrategyConfig returns sensible defaults for strategy configuration
func DefaultStrategyConfig() StrategyConfig {
return StrategyConfig{
MaxRetries: 5, // Retry 5 times
HealthCheckTimeout: 5 * time.Minute, // Max 5 mins health check
ParallelOperations: true, // Parallel execution
RetryDelay: 10 * time.Second, // 10s between retries
}
}
// StrategyFactory creates deployment strategy executors
type StrategyFactory struct {
config StrategyConfig
client EdgeConnectClientInterface
logger Logger
}
// NewStrategyFactory creates a new strategy factory
func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *StrategyFactory {
return &StrategyFactory{
config: config,
client: client,
logger: logger,
}
}
// CreateStrategy creates the appropriate strategy executor based on the deployment strategy
func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) {
switch strategy {
case StrategyRecreate:
return NewRecreateStrategy(f.client, f.config, f.logger), nil
case StrategyBlueGreen:
// TODO: Implement blue-green strategy
return nil, fmt.Errorf("blue-green strategy not yet implemented")
case StrategyRolling:
// TODO: Implement rolling strategy
return nil, fmt.Errorf("rolling strategy not yet implemented")
default:
return nil, fmt.Errorf("unknown deployment strategy: %s", strategy)
}
}
// GetAvailableStrategies returns a list of all available strategies
func (f *StrategyFactory) GetAvailableStrategies() []DeploymentStrategy {
return []DeploymentStrategy{
StrategyRecreate,
// StrategyBlueGreen, // TODO: Enable when implemented
// StrategyRolling, // TODO: Enable when implemented
}
}

View file

@ -0,0 +1,505 @@
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
package apply
import (
"context"
"fmt"
"strings"
"sync"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
)
// RecreateStrategy implements the recreate deployment strategy
type RecreateStrategy struct {
client EdgeConnectClientInterface
config StrategyConfig
logger Logger
}
// NewRecreateStrategy creates a new recreate strategy executor
func NewRecreateStrategy(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *RecreateStrategy {
return &RecreateStrategy{
client: client,
config: config,
logger: logger,
}
}
// GetName returns the strategy name
func (r *RecreateStrategy) GetName() DeploymentStrategy {
return StrategyRecreate
}
// Validate checks if the recreate strategy can be used for this deployment
func (r *RecreateStrategy) Validate(plan *DeploymentPlan) error {
// Recreate strategy can be used for any deployment
// No specific constraints for recreate
return nil
}
// EstimateDuration estimates the time needed for recreate deployment
func (r *RecreateStrategy) EstimateDuration(plan *DeploymentPlan) time.Duration {
var duration time.Duration
// Delete phase - estimate based on number of instances
instanceCount := len(plan.InstanceActions)
if instanceCount > 0 {
deleteTime := time.Duration(instanceCount) * 30 * time.Second
if r.config.ParallelOperations {
deleteTime = 30 * time.Second // Parallel deletion
}
duration += deleteTime
}
// App update phase
if plan.AppAction.Type == ActionUpdate {
duration += 30 * time.Second
}
// Create phase - estimate based on number of instances
if instanceCount > 0 {
createTime := time.Duration(instanceCount) * 2 * time.Minute
if r.config.ParallelOperations {
createTime = 2 * time.Minute // Parallel creation
}
duration += createTime
}
// Health check time
duration += r.config.HealthCheckTimeout
// Add retry buffer (potential retries)
retryBuffer := time.Duration(r.config.MaxRetries) * r.config.RetryDelay
duration += retryBuffer
return duration
}
// Execute runs the recreate deployment strategy
func (r *RecreateStrategy) Execute(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) {
startTime := time.Now()
r.logf("Starting recreate deployment strategy for: %s", plan.ConfigName)
result := &ExecutionResult{
Plan: plan,
CompletedActions: []ActionResult{},
FailedActions: []ActionResult{},
}
// Phase 1: Delete all existing instances
if err := r.deleteInstancesPhase(ctx, plan, config, result); err != nil {
result.Error = err
result.Duration = time.Since(startTime)
return result, err
}
// Phase 2: Delete existing app (if updating)
if err := r.deleteAppPhase(ctx, plan, config, result); err != nil {
result.Error = err
result.Duration = time.Since(startTime)
return result, err
}
// Phase 3: Create/recreate application
if err := r.createAppPhase(ctx, plan, config, manifestContent, result); err != nil {
result.Error = err
result.Duration = time.Since(startTime)
return result, err
}
// Phase 4: Create new instances
if err := r.createInstancesPhase(ctx, plan, config, result); err != nil {
result.Error = err
result.Duration = time.Since(startTime)
return result, err
}
// Phase 5: Health check (wait for instances to be ready)
if err := r.healthCheckPhase(ctx, plan, result); err != nil {
result.Error = err
result.Duration = time.Since(startTime)
return result, err
}
result.Success = len(result.FailedActions) == 0
result.Duration = time.Since(startTime)
if result.Success {
r.logf("Recreate deployment completed successfully in %v", result.Duration)
} else {
r.logf("Recreate deployment failed with %d failed actions", len(result.FailedActions))
}
return result, result.Error
}
// deleteInstancesPhase deletes all existing instances
func (r *RecreateStrategy) deleteInstancesPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
r.logf("Phase 1: Deleting existing instances")
// Only delete instances that exist (have ActionUpdate or ActionNone type)
instancesToDelete := []InstanceAction{}
for _, action := range plan.InstanceActions {
if action.Type == ActionUpdate || action.Type == ActionNone {
// Convert to delete action
deleteAction := action
deleteAction.Type = ActionDelete
deleteAction.Reason = "Recreate strategy: deleting for recreation"
instancesToDelete = append(instancesToDelete, deleteAction)
}
}
if len(instancesToDelete) == 0 {
r.logf("No existing instances to delete")
return nil
}
deleteResults := r.executeInstanceActionsWithRetry(ctx, instancesToDelete, "delete", config)
for _, deleteResult := range deleteResults {
if deleteResult.Success {
result.CompletedActions = append(result.CompletedActions, deleteResult)
r.logf("Deleted instance: %s", deleteResult.Target)
} else {
result.FailedActions = append(result.FailedActions, deleteResult)
return fmt.Errorf("failed to delete instance %s: %w", deleteResult.Target, deleteResult.Error)
}
}
r.logf("Phase 1 complete: deleted %d instances", len(deleteResults))
return nil
}
// deleteAppPhase deletes the existing app (if updating)
func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
if plan.AppAction.Type != ActionUpdate {
r.logf("Phase 2: No app deletion needed (new app)")
return nil
}
r.logf("Phase 2: Deleting existing application")
appKey := edgeconnect.AppKey{
Organization: plan.AppAction.Desired.Organization,
Name: plan.AppAction.Desired.Name,
Version: plan.AppAction.Desired.Version,
}
if err := r.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region); err != nil {
result.FailedActions = append(result.FailedActions, ActionResult{
Type: ActionDelete,
Target: plan.AppAction.Desired.Name,
Success: false,
Error: err,
})
return fmt.Errorf("failed to delete app: %w", err)
}
result.CompletedActions = append(result.CompletedActions, ActionResult{
Type: ActionDelete,
Target: plan.AppAction.Desired.Name,
Success: true,
Details: fmt.Sprintf("Deleted app %s", plan.AppAction.Desired.Name),
})
r.logf("Phase 2 complete: deleted existing application")
return nil
}
// createAppPhase creates the application (always create since we deleted it first)
func (r *RecreateStrategy) createAppPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string, result *ExecutionResult) error {
if plan.AppAction.Type == ActionNone {
r.logf("Phase 3: No app creation needed")
return nil
}
r.logf("Phase 3: Creating application")
// Always use create since recreate strategy deletes first
createAction := plan.AppAction
createAction.Type = ActionCreate
createAction.Reason = "Recreate strategy: creating app"
appResult := r.executeAppActionWithRetry(ctx, createAction, config, manifestContent)
if appResult.Success {
result.CompletedActions = append(result.CompletedActions, appResult)
r.logf("Phase 3 complete: app created successfully")
return nil
} else {
result.FailedActions = append(result.FailedActions, appResult)
return fmt.Errorf("failed to create app: %w", appResult.Error)
}
}
// createInstancesPhase creates new instances
func (r *RecreateStrategy) createInstancesPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
r.logf("Phase 4: Creating new instances")
// Convert all instance actions to create
instancesToCreate := []InstanceAction{}
for _, action := range plan.InstanceActions {
createAction := action
createAction.Type = ActionCreate
createAction.Reason = "Recreate strategy: creating new instance"
instancesToCreate = append(instancesToCreate, createAction)
}
if len(instancesToCreate) == 0 {
r.logf("No instances to create")
return nil
}
createResults := r.executeInstanceActionsWithRetry(ctx, instancesToCreate, "create", config)
for _, createResult := range createResults {
if createResult.Success {
result.CompletedActions = append(result.CompletedActions, createResult)
r.logf("Created instance: %s", createResult.Target)
} else {
result.FailedActions = append(result.FailedActions, createResult)
return fmt.Errorf("failed to create instance %s: %w", createResult.Target, createResult.Error)
}
}
r.logf("Phase 4 complete: created %d instances", len(createResults))
return nil
}
// healthCheckPhase waits for instances to become ready
func (r *RecreateStrategy) healthCheckPhase(ctx context.Context, plan *DeploymentPlan, result *ExecutionResult) error {
if len(plan.InstanceActions) == 0 {
return nil
}
r.logf("Phase 5: Performing health checks")
// TODO: Implement actual health checks by querying instance status
// For now, skip waiting in tests/mock environments
r.logf("Phase 5 complete: health check passed (no wait)")
return nil
}
// executeInstanceActionsWithRetry executes instance actions with retry logic
func (r *RecreateStrategy) executeInstanceActionsWithRetry(ctx context.Context, actions []InstanceAction, operation string, config *config.EdgeConnectConfig) []ActionResult {
results := make([]ActionResult, len(actions))
if r.config.ParallelOperations && len(actions) > 1 {
// Parallel execution
var wg sync.WaitGroup
semaphore := make(chan struct{}, 5) // Limit concurrency
for i, action := range actions {
wg.Add(1)
go func(index int, instanceAction InstanceAction) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
results[index] = r.executeInstanceActionWithRetry(ctx, instanceAction, operation, config)
}(i, action)
}
wg.Wait()
} else {
// Sequential execution
for i, action := range actions {
results[i] = r.executeInstanceActionWithRetry(ctx, action, operation, config)
}
}
return results
}
// executeInstanceActionWithRetry executes a single instance action with retry logic
func (r *RecreateStrategy) executeInstanceActionWithRetry(ctx context.Context, action InstanceAction, operation string, config *config.EdgeConnectConfig) ActionResult {
startTime := time.Now()
result := ActionResult{
Type: action.Type,
Target: action.InstanceName,
}
var lastErr error
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
if attempt > 0 {
r.logf("Retrying %s for instance %s (attempt %d/%d)", operation, action.InstanceName, attempt, r.config.MaxRetries)
select {
case <-time.After(r.config.RetryDelay):
case <-ctx.Done():
result.Error = ctx.Err()
result.Duration = time.Since(startTime)
return result
}
}
var success bool
var err error
switch action.Type {
case ActionDelete:
success, err = r.deleteInstance(ctx, action)
case ActionCreate:
success, err = r.createInstance(ctx, action, config)
default:
err = fmt.Errorf("unsupported action type: %s", action.Type)
}
if success {
result.Success = true
result.Details = fmt.Sprintf("Successfully %sd instance %s", strings.ToLower(string(action.Type)), action.InstanceName)
result.Duration = time.Since(startTime)
return result
}
lastErr = err
if attempt < r.config.MaxRetries {
r.logf("Failed to %s instance %s: %v (will retry)", operation, action.InstanceName, err)
}
}
result.Error = fmt.Errorf("failed after %d attempts: %w", r.config.MaxRetries+1, lastErr)
result.Duration = time.Since(startTime)
return result
}
// executeAppActionWithRetry executes app action with retry logic
func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) ActionResult {
startTime := time.Now()
result := ActionResult{
Type: action.Type,
Target: action.Desired.Name,
}
var lastErr error
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
if attempt > 0 {
r.logf("Retrying app update (attempt %d/%d)", attempt, r.config.MaxRetries)
select {
case <-time.After(r.config.RetryDelay):
case <-ctx.Done():
result.Error = ctx.Err()
result.Duration = time.Since(startTime)
return result
}
}
success, err := r.updateApplication(ctx, action, config, manifestContent)
if success {
result.Success = true
result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name)
result.Duration = time.Since(startTime)
return result
}
lastErr = err
if attempt < r.config.MaxRetries {
r.logf("Failed to update app: %v (will retry)", err)
}
}
result.Error = fmt.Errorf("failed after %d attempts: %w", r.config.MaxRetries+1, lastErr)
result.Duration = time.Since(startTime)
return result
}
// deleteInstance deletes an instance (reuse existing logic from manager.go)
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
instanceKey := edgeconnect.AppInstanceKey{
Organization: action.Target.Organization,
Name: action.InstanceName,
CloudletKey: edgeconnect.CloudletKey{
Organization: action.Target.CloudletOrg,
Name: action.Target.CloudletName,
},
}
err := r.client.DeleteAppInstance(ctx, instanceKey, action.Target.Region)
if err != nil {
return false, fmt.Errorf("failed to delete instance: %w", err)
}
return true, nil
}
// createInstance creates an instance (extracted from manager.go logic)
func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
instanceInput := &edgeconnect.NewAppInstanceInput{
Region: action.Target.Region,
AppInst: edgeconnect.AppInstance{
Key: edgeconnect.AppInstanceKey{
Organization: action.Target.Organization,
Name: action.InstanceName,
CloudletKey: edgeconnect.CloudletKey{
Organization: action.Target.CloudletOrg,
Name: action.Target.CloudletName,
},
},
AppKey: edgeconnect.AppKey{
Organization: action.Target.Organization,
Name: config.Metadata.Name,
Version: config.Spec.GetAppVersion(),
},
Flavor: edgeconnect.Flavor{
Name: action.Target.FlavorName,
},
},
}
// Create the instance
if err := r.client.CreateAppInstance(ctx, instanceInput); err != nil {
return false, fmt.Errorf("failed to create instance: %w", err)
}
r.logf("Successfully created instance: %s on %s:%s",
action.InstanceName, action.Target.CloudletOrg, action.Target.CloudletName)
return true, nil
}
// updateApplication creates/recreates an application (always uses CreateApp since we delete first)
func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) {
// Build the app create input - always create since recreate strategy deletes first
appInput := &edgeconnect.NewAppInput{
Region: action.Desired.Region,
App: edgeconnect.App{
Key: edgeconnect.AppKey{
Organization: action.Desired.Organization,
Name: action.Desired.Name,
Version: action.Desired.Version,
},
Deployment: config.GetDeploymentType(),
ImageType: "ImageTypeDocker",
ImagePath: config.GetImagePath(),
AllowServerless: true,
DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
ServerlessConfig: struct{}{},
DeploymentManifest: manifestContent,
DeploymentGenerator: "kubernetes-basic",
},
}
// Add network configuration if specified
if config.Spec.Network != nil {
appInput.App.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network)
}
// Create the application (recreate strategy always creates from scratch)
if err := r.client.CreateApp(ctx, appInput); err != nil {
return false, fmt.Errorf("failed to create application: %w", err)
}
r.logf("Successfully created application: %s/%s version %s",
action.Desired.Organization, action.Desired.Name, action.Desired.Version)
return true, nil
}
// logf logs a message if a logger is configured
func (r *RecreateStrategy) logf(format string, v ...interface{}) {
if r.logger != nil {
r.logger.Printf("[RecreateStrategy] "+format, v...)
}
}

View file

@ -7,8 +7,12 @@ import (
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
)
// SecurityRule defines network access rules (alias to SDK type for consistency)
type SecurityRule = edgeconnect.SecurityRule
// ActionType represents the type of action to be performed
type ActionType string
@ -131,6 +135,9 @@ type AppState struct {
// AppType indicates whether this is a k8s or docker app
AppType AppType
// OutboundConnections contains the required outbound network connections
OutboundConnections []SecurityRule
}
// InstanceState represents the current state of an application instance
@ -426,3 +433,19 @@ func (dp *DeploymentPlan) Clone() *DeploymentPlan {
return clone
}
// convertNetworkRules converts config network rules to EdgeConnect SecurityRules
func convertNetworkRules(network *config.NetworkConfig) []edgeconnect.SecurityRule {
rules := make([]edgeconnect.SecurityRule, len(network.OutboundConnections))
for i, conn := range network.OutboundConnections {
rules[i] = edgeconnect.SecurityRule{
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
}
return rules
}

View file

@ -0,0 +1,46 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetDeploymentType(t *testing.T) {
// Test k8s app
k8sConfig := &EdgeConnectConfig{
Spec: Spec{
K8sApp: &K8sApp{},
},
}
assert.Equal(t, "kubernetes", k8sConfig.GetDeploymentType())
// Test docker app
dockerConfig := &EdgeConnectConfig{
Spec: Spec{
DockerApp: &DockerApp{},
},
}
assert.Equal(t, "docker", dockerConfig.GetDeploymentType())
}
func TestGetImagePath(t *testing.T) {
// Test docker app with image
dockerConfig := &EdgeConnectConfig{
Spec: Spec{
DockerApp: &DockerApp{
Image: "my-custom-image:latest",
},
},
}
assert.Equal(t, "my-custom-image:latest", dockerConfig.GetImagePath())
// Test k8s app (should use default)
k8sConfig := &EdgeConnectConfig{
Spec: Spec{
K8sApp: &K8sApp{},
},
}
assert.Equal(t, "https://registry-1.docker.io/library/nginx:latest", k8sConfig.GetImagePath())
}

View file

@ -15,11 +15,12 @@ func TestParseExampleConfig(t *testing.T) {
// Parse the actual example file (now that we've created the manifest file)
examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig.yaml")
config, err := parser.ParseFile(examplePath)
config, parsedManifest, err := parser.ParseFile(examplePath)
// This should now succeed with full validation
require.NoError(t, err)
require.NotNil(t, config)
require.NotEmpty(t, parsedManifest)
// Validate the parsed structure
assert.Equal(t, "edgeconnect-deployment", config.Kind)
@ -62,10 +63,6 @@ func TestParseExampleConfig(t *testing.T) {
assert.Contains(t, config.Spec.GetManifestFile(), "k8s-deployment.yaml")
assert.True(t, config.Spec.IsK8sApp())
assert.False(t, config.Spec.IsDockerApp())
// Test instance name generation
instanceName := GetInstanceName(config.Metadata.Name, config.Spec.GetAppVersion())
assert.Equal(t, "edge-app-demo-1.0.0-instance", instanceName)
}
func TestValidateExampleStructure(t *testing.T) {
@ -113,16 +110,4 @@ func TestValidateExampleStructure(t *testing.T) {
// This should validate successfully
err := parser.Validate(config)
assert.NoError(t, err)
// Test comprehensive validation
err = parser.ComprehensiveValidate(config)
assert.NoError(t, err)
// Test infrastructure uniqueness validation
err = parser.ValidateInfrastructureUniqueness(config)
assert.NoError(t, err)
// Test port range validation
err = parser.ValidatePortRanges(config)
assert.NoError(t, err)
}

View file

@ -12,7 +12,7 @@ import (
// Parser defines the interface for configuration parsing
type Parser interface {
ParseFile(filename string) (*EdgeConnectConfig, error)
ParseFile(filename string) (*EdgeConnectConfig, string, error)
ParseBytes(data []byte) (*EdgeConnectConfig, error)
Validate(config *EdgeConnectConfig) error
}
@ -26,40 +26,45 @@ func NewParser() Parser {
}
// ParseFile parses an EdgeConnectConfig from a YAML file
func (p *ConfigParser) ParseFile(filename string) (*EdgeConnectConfig, error) {
func (p *ConfigParser) ParseFile(filename string) (*EdgeConnectConfig, string, error) {
if filename == "" {
return nil, fmt.Errorf("filename cannot be empty")
return nil, "", fmt.Errorf("filename cannot be empty")
}
// Check if file exists
if _, err := os.Stat(filename); os.IsNotExist(err) {
return nil, fmt.Errorf("configuration file does not exist: %s", filename)
return nil, "", fmt.Errorf("configuration file does not exist: %s", filename)
}
// Read file contents
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read configuration file %s: %w", filename, err)
return nil, "", fmt.Errorf("failed to read configuration file %s: %w", filename, err)
}
// Parse YAML without validation first
config, err := p.parseYAMLOnly(data)
if err != nil {
return nil, fmt.Errorf("failed to parse configuration file %s: %w", filename, err)
return nil, "", fmt.Errorf("failed to parse configuration file %s: %w", filename, err)
}
// Resolve relative paths relative to config file directory
configDir := filepath.Dir(filename)
if err := p.resolveRelativePaths(config, configDir); err != nil {
return nil, fmt.Errorf("failed to resolve paths in %s: %w", filename, err)
return nil, "", fmt.Errorf("failed to resolve paths in %s: %w", filename, err)
}
// Now validate with resolved paths
if err := p.Validate(config); err != nil {
return nil, fmt.Errorf("configuration validation failed in %s: %w", filename, err)
return nil, "", fmt.Errorf("configuration validation failed in %s: %w", filename, err)
}
return config, nil
manifest, err := p.readManifestFiles(config)
if err != nil {
return nil, "", fmt.Errorf("failed to read manifest files: %w", err)
}
return config, manifest, nil
}
// parseYAMLOnly parses YAML without validation
@ -122,7 +127,7 @@ func (p *ConfigParser) resolveRelativePaths(config *EdgeConnectConfig, configDir
}
// ValidateManifestFiles performs additional validation on manifest files
func (p *ConfigParser) ValidateManifestFiles(config *EdgeConnectConfig) error {
func (p *ConfigParser) readManifestFiles(config *EdgeConnectConfig) (string, error) {
var manifestFile string
if config.Spec.K8sApp != nil {
@ -131,13 +136,23 @@ func (p *ConfigParser) ValidateManifestFiles(config *EdgeConnectConfig) error {
manifestFile = config.Spec.DockerApp.ManifestFile
}
if manifestFile == "" {
return "", nil
}
if manifestFile != "" {
if err := p.validateManifestFile(manifestFile); err != nil {
return fmt.Errorf("manifest file validation failed: %w", err)
return "", fmt.Errorf("manifest file validation failed: %w", err)
}
}
return nil
// Try to read the file to ensure it's accessible
content, err := os.ReadFile(manifestFile)
if err != nil {
return "", fmt.Errorf("cannot read manifest file %s: %w", manifestFile, err)
}
return string(content), nil
}
// validateManifestFile checks if the manifest file is valid and readable
@ -155,94 +170,5 @@ func (p *ConfigParser) validateManifestFile(filename string) error {
return fmt.Errorf("manifest file cannot be empty: %s", filename)
}
// Try to read the file to ensure it's accessible
if _, err := os.ReadFile(filename); err != nil {
return fmt.Errorf("cannot read manifest file %s: %w", filename, err)
}
return nil
}
// GetInstanceName generates the instance name following the pattern: appName-appVersion-instance
func GetInstanceName(appName, appVersion string) string {
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
}
// ValidateInfrastructureUniqueness ensures no duplicate infrastructure targets
func (p *ConfigParser) ValidateInfrastructureUniqueness(config *EdgeConnectConfig) error {
seen := make(map[string]bool)
for i, infra := range config.Spec.InfraTemplate {
key := fmt.Sprintf("%s:%s:%s:%s",
infra.Organization,
infra.Region,
infra.CloudletOrg,
infra.CloudletName)
if seen[key] {
return fmt.Errorf("duplicate infrastructure target at index %d: org=%s, region=%s, cloudletOrg=%s, cloudletName=%s",
i, infra.Organization, infra.Region, infra.CloudletOrg, infra.CloudletName)
}
seen[key] = true
}
return nil
}
// ValidatePortRanges ensures port ranges don't overlap in network configuration
func (p *ConfigParser) ValidatePortRanges(config *EdgeConnectConfig) error {
if config.Spec.Network == nil {
return nil
}
connections := config.Spec.Network.OutboundConnections
for i := 0; i < len(connections); i++ {
for j := i + 1; j < len(connections); j++ {
conn1 := connections[i]
conn2 := connections[j]
// Only check same protocol and CIDR
if conn1.Protocol == conn2.Protocol && conn1.RemoteCIDR == conn2.RemoteCIDR {
if portRangesOverlap(conn1.PortRangeMin, conn1.PortRangeMax, conn2.PortRangeMin, conn2.PortRangeMax) {
return fmt.Errorf("overlapping port ranges for protocol %s and CIDR %s: [%d-%d] overlaps with [%d-%d]",
conn1.Protocol, conn1.RemoteCIDR,
conn1.PortRangeMin, conn1.PortRangeMax,
conn2.PortRangeMin, conn2.PortRangeMax)
}
}
}
}
return nil
}
// portRangesOverlap checks if two port ranges overlap
func portRangesOverlap(min1, max1, min2, max2 int) bool {
return max1 >= min2 && max2 >= min1
}
// ComprehensiveValidate performs all validation checks including extended ones
func (p *ConfigParser) ComprehensiveValidate(config *EdgeConnectConfig) error {
// Basic validation
if err := p.Validate(config); err != nil {
return err
}
// Manifest file validation
if err := p.ValidateManifestFiles(config); err != nil {
return err
}
// Infrastructure uniqueness validation
if err := p.ValidateInfrastructureUniqueness(config); err != nil {
return err
}
// Port range validation
if err := p.ValidatePortRanges(config); err != nil {
return err
}
return nil
}

View file

@ -239,7 +239,7 @@ spec:
require.NoError(t, err)
// Test valid file parsing
config, err := parser.ParseFile(validFile)
config, _, err := parser.ParseFile(validFile)
assert.NoError(t, err)
assert.NotNil(t, config)
assert.Equal(t, "edgeconnect-deployment", config.Kind)
@ -247,13 +247,13 @@ spec:
// Test non-existent file
nonExistentFile := filepath.Join(tempDir, "nonexistent.yaml")
config, err = parser.ParseFile(nonExistentFile)
config, _, err = parser.ParseFile(nonExistentFile)
assert.Error(t, err)
assert.Contains(t, err.Error(), "does not exist")
assert.Nil(t, config)
// Test empty filename
config, err = parser.ParseFile("")
config, _, err = parser.ParseFile("")
assert.Error(t, err)
assert.Contains(t, err.Error(), "filename cannot be empty")
assert.Nil(t, config)
@ -263,7 +263,7 @@ spec:
err = os.WriteFile(invalidFile, []byte("invalid: yaml: content: ["), 0644)
require.NoError(t, err)
config, err = parser.ParseFile(invalidFile)
config, _, err = parser.ParseFile(invalidFile)
assert.Error(t, err)
assert.Contains(t, err.Error(), "YAML parsing failed")
assert.Nil(t, config)
@ -300,7 +300,7 @@ spec:
err = os.WriteFile(configFile, []byte(configContent), 0644)
require.NoError(t, err)
config, err := parser.ParseFile(configFile)
config, _, err := parser.ParseFile(configFile)
assert.NoError(t, err)
assert.NotNil(t, config)
@ -523,206 +523,6 @@ func TestOutboundConnection_Validate(t *testing.T) {
}
}
func TestConfigParser_ValidateInfrastructureUniqueness(t *testing.T) {
parser := &ConfigParser{}
tests := []struct {
name string
config *EdgeConnectConfig
wantErr bool
errMsg string
}{
{
name: "unique infrastructure",
config: &EdgeConnectConfig{
Spec: Spec{
InfraTemplate: []InfraTemplate{
{
Organization: "org1",
Region: "US",
CloudletOrg: "cloudlet1",
CloudletName: "name1",
},
{
Organization: "org1",
Region: "EU",
CloudletOrg: "cloudlet1",
CloudletName: "name1",
},
},
},
},
wantErr: false,
},
{
name: "duplicate infrastructure",
config: &EdgeConnectConfig{
Spec: Spec{
InfraTemplate: []InfraTemplate{
{
Organization: "org1",
Region: "US",
CloudletOrg: "cloudlet1",
CloudletName: "name1",
},
{
Organization: "org1",
Region: "US",
CloudletOrg: "cloudlet1",
CloudletName: "name1",
},
},
},
},
wantErr: true,
errMsg: "duplicate infrastructure target",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := parser.ValidateInfrastructureUniqueness(tt.config)
if tt.wantErr {
assert.Error(t, err)
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestConfigParser_ValidatePortRanges(t *testing.T) {
parser := &ConfigParser{}
tests := []struct {
name string
config *EdgeConnectConfig
wantErr bool
errMsg string
}{
{
name: "no network config",
config: &EdgeConnectConfig{
Spec: Spec{
Network: nil,
},
},
wantErr: false,
},
{
name: "non-overlapping ports",
config: &EdgeConnectConfig{
Spec: Spec{
Network: &NetworkConfig{
OutboundConnections: []OutboundConnection{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 443,
PortRangeMax: 443,
RemoteCIDR: "0.0.0.0/0",
},
},
},
},
},
wantErr: false,
},
{
name: "overlapping ports same protocol and CIDR",
config: &EdgeConnectConfig{
Spec: Spec{
Network: &NetworkConfig{
OutboundConnections: []OutboundConnection{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 90,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 85,
PortRangeMax: 95,
RemoteCIDR: "0.0.0.0/0",
},
},
},
},
},
wantErr: true,
errMsg: "overlapping port ranges",
},
{
name: "overlapping ports different protocol",
config: &EdgeConnectConfig{
Spec: Spec{
Network: &NetworkConfig{
OutboundConnections: []OutboundConnection{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 90,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "udp",
PortRangeMin: 85,
PortRangeMax: 95,
RemoteCIDR: "0.0.0.0/0",
},
},
},
},
},
wantErr: false, // Different protocols can overlap
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := parser.ValidatePortRanges(tt.config)
if tt.wantErr {
assert.Error(t, err)
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestGetInstanceName(t *testing.T) {
tests := []struct {
appName string
appVersion string
expected string
}{
{"myapp", "1.0.0", "myapp-1.0.0-instance"},
{"test-app", "v2.1", "test-app-v2.1-instance"},
{"app", "latest", "app-latest-instance"},
}
for _, tt := range tests {
t.Run(tt.appName+"-"+tt.appVersion, func(t *testing.T) {
result := GetInstanceName(tt.appName, tt.appVersion)
assert.Equal(t, tt.expected, result)
})
}
}
func TestSpec_GetMethods(t *testing.T) {
k8sSpec := &Spec{
K8sApp: &K8sApp{
@ -749,27 +549,43 @@ func TestSpec_GetMethods(t *testing.T) {
assert.True(t, dockerSpec.IsDockerApp())
}
func TestPortRangesOverlap(t *testing.T) {
tests := []struct {
name string
min1 int
max1 int
min2 int
max2 int
expected bool
}{
{"no overlap", 10, 20, 30, 40, false},
{"overlap", 10, 20, 15, 25, true},
{"adjacent", 10, 20, 21, 30, false},
{"touching", 10, 20, 20, 30, true},
{"contained", 10, 30, 15, 25, true},
{"same range", 10, 20, 10, 20, true},
}
func TestReadManifestFile(t *testing.T) {
parser := NewParser()
tempDir := t.TempDir()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := portRangesOverlap(tt.min1, tt.max1, tt.min2, tt.max2)
assert.Equal(t, tt.expected, result)
})
}
// Create a manifest file
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
manifestFile := filepath.Join(tempDir, "manifest.yaml")
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
require.NoError(t, err)
// Create config with relative path
configContent := `
kind: edgeconnect-deployment
metadata:
name: "test-app"
spec:
k8sApp:
appVersion: "1.0.0"
manifestFile: "./manifest.yaml"
infraTemplate:
- organization: "testorg"
region: "US"
cloudletOrg: "TestOP"
cloudletName: "TestCloudlet"
flavorName: "small"
`
configFile := filepath.Join(tempDir, "config.yaml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
require.NoError(t, err)
config, parsedManifestContent, err := parser.ParseFile(configFile)
assert.NoError(t, err)
assert.Equal(t, manifestContent, parsedManifestContent)
assert.NotNil(t, config)
// Check that relative path was resolved to absolute
expectedPath := filepath.Join(tempDir, "manifest.yaml")
assert.Equal(t, expectedPath, config.Spec.K8sApp.ManifestFile)
}

View file

@ -23,10 +23,11 @@ type Metadata struct {
// Spec defines the application and infrastructure specification
type Spec struct {
K8sApp *K8sApp `yaml:"k8sApp,omitempty"`
DockerApp *DockerApp `yaml:"dockerApp,omitempty"`
InfraTemplate []InfraTemplate `yaml:"infraTemplate"`
Network *NetworkConfig `yaml:"network,omitempty"`
K8sApp *K8sApp `yaml:"k8sApp,omitempty"`
DockerApp *DockerApp `yaml:"dockerApp,omitempty"`
InfraTemplate []InfraTemplate `yaml:"infraTemplate"`
Network *NetworkConfig `yaml:"network,omitempty"`
DeploymentStrategy string `yaml:"deploymentStrategy,omitempty"`
}
// K8sApp defines Kubernetes application configuration
@ -85,6 +86,23 @@ func (c *EdgeConnectConfig) Validate() error {
return nil
}
// getDeploymentType determines the deployment type from config
func (c *EdgeConnectConfig) GetDeploymentType() string {
if c.Spec.IsK8sApp() {
return "kubernetes"
}
return "docker"
}
// getImagePath gets the image path for the application
func (c *EdgeConnectConfig) GetImagePath() string {
if c.Spec.IsDockerApp() && c.Spec.DockerApp.Image != "" {
return c.Spec.DockerApp.Image
}
// Default for kubernetes apps
return "https://registry-1.docker.io/library/nginx:latest"
}
// Validate validates metadata fields
func (m *Metadata) Validate() error {
if m.Name == "" {
@ -141,6 +159,13 @@ func (s *Spec) Validate() error {
}
}
// Validate deployment strategy if present
if s.DeploymentStrategy != "" {
if err := s.ValidateDeploymentStrategy(); err != nil {
return fmt.Errorf("deploymentStrategy validation failed: %w", err)
}
}
return nil
}
@ -332,3 +357,27 @@ func (s *Spec) IsK8sApp() bool {
func (s *Spec) IsDockerApp() bool {
return s.DockerApp != nil
}
// ValidateDeploymentStrategy validates the deployment strategy value
func (s *Spec) ValidateDeploymentStrategy() error {
validStrategies := map[string]bool{
"recreate": true,
"blue-green": true, // Future implementation
"rolling": true, // Future implementation
}
strategy := strings.ToLower(strings.TrimSpace(s.DeploymentStrategy))
if !validStrategies[strategy] {
return fmt.Errorf("deploymentStrategy must be one of: recreate, blue-green, rolling")
}
return nil
}
// GetDeploymentStrategy returns the deployment strategy, defaulting to "recreate"
func (s *Spec) GetDeploymentStrategy() string {
if s.DeploymentStrategy == "" {
return "recreate"
}
return strings.ToLower(strings.TrimSpace(s.DeploymentStrategy))
}