// ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison // ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls package apply import ( "context" "crypto/sha256" "fmt" "io" "os" "strings" "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/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 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 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.Spec.GetAppVersion(), Organization: config.Spec.InfraTemplate[0].Organization, // Use first infra template for org Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region Exists: false, // Will be set based on current state } if config.Spec.IsK8sApp() { desired.AppType = AppTypeK8s } else { desired.AppType = AppTypeDocker } // Calculate manifest hash manifestHash, err := p.calculateManifestHash(config.Spec.GetManifestFile()) if err != nil { return nil, warnings, fmt.Errorf("failed to calculate manifest hash: %w", err) } desired.ManifestHash = manifestHash action := &AppAction{ Type: ActionNone, Desired: desired, ManifestHash: manifestHash, Reason: "No action needed", } // Skip state check if requested (useful for testing) if opts.SkipStateCheck { action.Type = ActionCreate action.Reason = "Creating app (state check skipped)" action.Changes = []string{"Create new application"} return action, warnings, nil } // Query current app state current, err := p.getCurrentAppState(ctx, desired, opts.Timeout) if err != nil { // If app doesn't exist, we need to create it if isResourceNotFoundError(err) { action.Type = ActionCreate action.Reason = "Application does not exist" action.Changes = []string{"Create new application"} return action, warnings, nil } return nil, warnings, fmt.Errorf("failed to query current app state: %w", err) } action.Current = current // Compare current vs desired state changes, manifestChanged := p.compareAppStates(current, desired) action.ManifestChanged = manifestChanged if len(changes) > 0 { action.Type = ActionUpdate action.Changes = changes action.Reason = "Application configuration has changed" if manifestChanged { warnings = append(warnings, "Manifest file has changed - instances may need to be recreated") } } return action, warnings, nil } // planInstanceActions determines what actions need to be taken for instances func (p *EdgeConnectPlanner) planInstanceActions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) ([]InstanceAction, []string, error) { var actions []InstanceAction var warnings []string for _, infra := range config.Spec.InfraTemplate { instanceName := getInstanceName(config.Metadata.Name, config.Spec.GetAppVersion()) desired := &InstanceState{ Name: instanceName, AppVersion: config.Spec.GetAppVersion(), Organization: infra.Organization, Region: infra.Region, CloudletOrg: infra.CloudletOrg, CloudletName: infra.CloudletName, FlavorName: infra.FlavorName, Exists: false, } action := &InstanceAction{ Type: ActionNone, Target: infra, Desired: desired, InstanceName: instanceName, Reason: "No action needed", } // Skip state check if requested if opts.SkipStateCheck { action.Type = ActionCreate action.Reason = "Creating instance (state check skipped)" action.Changes = []string{"Create new instance"} actions = append(actions, *action) continue } // Query current instance state current, err := p.getCurrentInstanceState(ctx, desired, opts.Timeout) if err != nil { // If instance doesn't exist, we need to create it if isResourceNotFoundError(err) { action.Type = ActionCreate action.Reason = "Instance does not exist" action.Changes = []string{"Create new instance"} actions = append(actions, *action) continue } return nil, warnings, fmt.Errorf("failed to query current instance state: %w", err) } action.Current = current // Compare current vs desired state changes := p.compareInstanceStates(current, desired) if len(changes) > 0 { action.Type = ActionUpdate action.Changes = changes action.Reason = "Instance configuration has changed" } actions = append(actions, *action) } return actions, warnings, nil } // getCurrentAppState queries the current state of an application func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *AppState, timeout time.Duration) (*AppState, error) { timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() appKey := edgeconnect.AppKey{ Organization: desired.Organization, Name: desired.Name, Version: desired.Version, } app, err := p.client.ShowApp(timeoutCtx, appKey, desired.Region) if err != nil { return nil, err } current := &AppState{ Name: app.Key.Name, Version: app.Key.Version, Organization: app.Key.Organization, Region: desired.Region, Exists: true, LastUpdated: time.Now(), // EdgeConnect doesn't provide this, so use current time } // Note: EdgeConnect API doesn't currently support annotations for manifest hash tracking // This would be implemented when the API supports it // Determine app type based on deployment type if app.Deployment == "kubernetes" { current.AppType = AppTypeK8s } else { current.AppType = AppTypeDocker } return current, nil } // getCurrentInstanceState queries the current state of an application instance func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desired *InstanceState, timeout time.Duration) (*InstanceState, error) { timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() instanceKey := edgeconnect.AppInstanceKey{ Organization: desired.Organization, Name: desired.Name, CloudletKey: edgeconnect.CloudletKey{ Organization: desired.CloudletOrg, Name: desired.CloudletName, }, } instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region) if err != nil { return nil, err } current := &InstanceState{ Name: instance.Key.Name, AppName: instance.AppKey.Name, AppVersion: instance.AppKey.Version, Organization: instance.Key.Organization, Region: desired.Region, CloudletOrg: instance.Key.CloudletKey.Organization, CloudletName: instance.Key.CloudletKey.Name, FlavorName: instance.Flavor.Name, State: instance.State, PowerState: instance.PowerState, Exists: true, LastUpdated: time.Now(), // EdgeConnect doesn't provide this } return current, nil } // compareAppStates compares current and desired app states and returns changes func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]string, bool) { var changes []string manifestChanged := false // Compare manifest hash - only if both states have hash values // Since EdgeConnect API doesn't support annotations yet, skip manifest hash comparison for now // This would be implemented when the API supports manifest hash tracking if current.ManifestHash != "" && desired.ManifestHash != "" && current.ManifestHash != desired.ManifestHash { changes = append(changes, fmt.Sprintf("Manifest hash changed: %s -> %s", current.ManifestHash, desired.ManifestHash)) manifestChanged = true } // Compare app type if current.AppType != desired.AppType { changes = append(changes, fmt.Sprintf("App type changed: %s -> %s", current.AppType, desired.AppType)) } return changes, manifestChanged } // compareInstanceStates compares current and desired instance states and returns changes func (p *EdgeConnectPlanner) compareInstanceStates(current, desired *InstanceState) []string { var changes []string if current.FlavorName != desired.FlavorName { changes = append(changes, fmt.Sprintf("Flavor changed: %s -> %s", current.FlavorName, desired.FlavorName)) } if current.CloudletName != desired.CloudletName { changes = append(changes, fmt.Sprintf("Cloudlet changed: %s -> %s", current.CloudletName, desired.CloudletName)) } if current.CloudletOrg != desired.CloudletOrg { changes = append(changes, fmt.Sprintf("Cloudlet org changed: %s -> %s", current.CloudletOrg, desired.CloudletOrg)) } return changes } // calculateManifestHash computes the SHA256 hash of a manifest file func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string, error) { if manifestPath == "" { return "", nil } file, err := os.Open(manifestPath) if err != nil { return "", fmt.Errorf("failed to open manifest file: %w", err) } defer file.Close() hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { return "", fmt.Errorf("failed to hash manifest file: %w", err) } return fmt.Sprintf("%x", hasher.Sum(nil)), nil } // calculatePlanMetadata computes metadata for the deployment plan func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeploymentPlan) { totalActions := 0 if plan.AppAction.Type != ActionNone { totalActions++ } for _, action := range plan.InstanceActions { if action.Type != ActionNone { totalActions++ } } plan.TotalActions = totalActions // Estimate duration based on action types and counts plan.EstimatedDuration = p.estimateDeploymentDuration(plan) } // estimateDeploymentDuration provides a rough estimate of deployment time func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) time.Duration { var duration time.Duration // App operations if plan.AppAction.Type == ActionCreate { duration += 30 * time.Second } else if plan.AppAction.Type == ActionUpdate { duration += 15 * time.Second } // Instance operations (can be done in parallel) instanceDuration := time.Duration(0) for _, action := range plan.InstanceActions { if action.Type == ActionCreate { instanceDuration = max(instanceDuration, 2*time.Minute) } else if action.Type == ActionUpdate { instanceDuration = max(instanceDuration, 1*time.Minute) } } duration += instanceDuration // Add buffer time duration += 30 * time.Second return duration } // isResourceNotFoundError checks if an error indicates a resource was not found func isResourceNotFoundError(err error) bool { if err == nil { return false } errStr := strings.ToLower(err.Error()) return strings.Contains(errStr, "not found") || strings.Contains(errStr, "does not exist") || strings.Contains(errStr, "404") } // max returns the larger of two durations func max(a, b time.Duration) time.Duration { if a > b { return a } return b } // getInstanceName generates the instance name following the pattern: appName-appVersion-instance func getInstanceName(appName, appVersion string) string { return fmt.Sprintf("%s-%s-instance", appName, appVersion) }