feat(apply): add v1 API support to apply command
Refactor apply command to support both v1 and v2 APIs: - Split internal/apply into v1 and v2 subdirectories - v1: Uses sdk/edgeconnect (from revision/v1 branch) - v2: Uses sdk/edgeconnect/v2 - Update cmd/apply.go to route to appropriate version based on api_version config - Both versions now fully functional with their respective API endpoints Changes: - Created internal/apply/v1/ with v1 SDK implementation - Created internal/apply/v2/ with v2 SDK implementation - Updated cmd/apply.go with runApplyV1() and runApplyV2() functions - Removed validation error that rejected v1 - Apply command now respects --api-version flag and config setting Testing: - V1 with edge.platform: ✅ Generates deployment plan correctly - V2 with orca.platform: ✅ Works as before 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
59ba5ffb02
commit
98a8c4db4a
15 changed files with 3265 additions and 22 deletions
156
cmd/apply.go
156
cmd/apply.go
|
|
@ -10,7 +10,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"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)
|
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()
|
apiVersion := getAPIVersion()
|
||||||
|
|
||||||
|
// Step 4-6: Execute deployment based on API version
|
||||||
if apiVersion == "v1" {
|
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)
|
func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error {
|
||||||
client := newSDKClientV2()
|
// Create v1 client
|
||||||
|
client := newSDKClientV1()
|
||||||
|
|
||||||
// Step 5: Create deployment planner
|
// Create deployment planner
|
||||||
planner := apply.NewPlanner(client)
|
planner := applyv1.NewPlanner(client)
|
||||||
|
|
||||||
// Step 6: Generate deployment plan
|
// Generate deployment plan
|
||||||
fmt.Println("🔍 Analyzing current state and generating deployment plan...")
|
fmt.Println("🔍 Analyzing current state and generating deployment plan...")
|
||||||
|
|
||||||
planOptions := apply.DefaultPlanOptions()
|
planOptions := applyv1.DefaultPlanOptions()
|
||||||
planOptions.DryRun = isDryRun
|
planOptions.DryRun = isDryRun
|
||||||
|
|
||||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
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)
|
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("\n📋 Deployment Plan:")
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
fmt.Println(strings.Repeat("=", 50))
|
||||||
fmt.Println(result.Plan.Summary)
|
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 {
|
if isDryRun {
|
||||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8: Confirm deployment (in non-dry-run mode)
|
// Confirm deployment
|
||||||
if result.Plan.TotalActions == 0 {
|
if result.Plan.TotalActions == 0 {
|
||||||
fmt.Println("\n✅ No changes needed. Resources are already in desired state.")
|
fmt.Println("\n✅ No changes needed. Resources are already in desired state.")
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -124,16 +130,112 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 9: Execute deployment
|
// Execute deployment
|
||||||
fmt.Println("\n🚀 Starting 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)
|
deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("deployment failed: %w", err)
|
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 {
|
if deployResult.Success {
|
||||||
fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration)
|
fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration)
|
||||||
if len(deployResult.CompletedActions) > 0 {
|
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 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
286
internal/apply/v1/manager.go
Normal file
286
internal/apply/v1/manager.go
Normal file
|
|
@ -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...)
|
||||||
|
}
|
||||||
|
}
|
||||||
497
internal/apply/v1/manager_test.go
Normal file
497
internal/apply/v1/manager_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
555
internal/apply/v1/planner.go
Normal file
555
internal/apply/v1/planner.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
663
internal/apply/v1/planner_test.go
Normal file
663
internal/apply/v1/planner_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
|
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
|
||||||
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
|
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
|
||||||
package apply
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
548
internal/apply/v1/strategy_recreate.go
Normal file
548
internal/apply/v1/strategy_recreate.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
462
internal/apply/v1/types.go
Normal file
462
internal/apply/v1/types.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// ABOUTME: Resource management for EdgeConnect apply command with deployment execution and rollback
|
// 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
|
// ABOUTME: Handles actual deployment operations, manifest processing, and error recovery with parallel execution
|
||||||
package apply
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios
|
// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios
|
||||||
// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients
|
// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients
|
||||||
package apply
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison
|
// 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
|
// ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls
|
||||||
package apply
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios
|
// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios
|
||||||
// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios
|
// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios
|
||||||
package apply
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
106
internal/apply/v2/strategy.go
Normal file
106
internal/apply/v2/strategy.go
Normal 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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect
|
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect
|
||||||
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
|
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
|
||||||
package apply
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// ABOUTME: Deployment planning types for EdgeConnect apply command with state management
|
// ABOUTME: Deployment planning types for EdgeConnect apply command with state management
|
||||||
// ABOUTME: Defines structures for deployment plans, actions, and state comparison results
|
// ABOUTME: Defines structures for deployment plans, actions, and state comparison results
|
||||||
package apply
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue