Moves the `domain` and `ports` packages from `internal/core` to `internal`. This refactoring simplifies the directory structure by elevating the core architectural concepts of domain and ports to the top level of the `internal` directory. The `core` directory is now removed as its only purpose was to house these two packages. All import paths across the project have been updated to reflect this change.
553 lines
No EOL
17 KiB
Go
553 lines
No EOL
17 KiB
Go
// 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/infrastructure/config"
|
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
|
)
|
|
|
|
// 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 {
|
|
appRepo driven.AppRepository
|
|
appInstRepo driven.AppInstanceRepository
|
|
}
|
|
|
|
// NewPlanner creates a new EdgeConnect deployment planner
|
|
func NewPlanner(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) Planner {
|
|
return &EdgeConnectPlanner{
|
|
appRepo: appRepo,
|
|
appInstRepo: appInstRepo,
|
|
}
|
|
}
|
|
|
|
// 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([]domain.SecurityRule, len(config.Spec.Network.OutboundConnections))
|
|
for i, conn := range config.Spec.Network.OutboundConnections {
|
|
desired.OutboundConnections[i] = domain.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 := domain.AppKey{
|
|
Organization: desired.Organization,
|
|
Name: desired.Name,
|
|
Version: desired.Version,
|
|
}
|
|
|
|
app, err := p.appRepo.ShowApp(timeoutCtx, desired.Region, appKey)
|
|
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([]domain.SecurityRule, len(app.RequiredOutboundConnections))
|
|
for i, conn := range app.RequiredOutboundConnections {
|
|
current.OutboundConnections[i] = domain.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 := domain.AppInstanceKey{
|
|
Organization: desired.Organization,
|
|
Name: desired.Name,
|
|
CloudletKey: domain.CloudletKey{
|
|
Organization: desired.CloudletOrg,
|
|
Name: desired.CloudletName,
|
|
},
|
|
}
|
|
|
|
instance, err := p.appInstRepo.ShowAppInstance(timeoutCtx, desired.Region, instanceKey)
|
|
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 []domain.SecurityRule) []string {
|
|
var changes []string
|
|
makeMap := func(rules []domain.SecurityRule) map[string]domain.SecurityRule {
|
|
m := make(map[string]domain.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 func() {
|
|
if err := file.Close(); err != nil {
|
|
// Log error but don't fail the operation as hash is already computed
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to close manifest file: %v\n", err)
|
|
}
|
|
}()
|
|
|
|
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
|
|
switch plan.AppAction.Type {
|
|
case ActionCreate:
|
|
duration += 30 * time.Second
|
|
case ActionUpdate:
|
|
duration += 15 * time.Second
|
|
}
|
|
|
|
// Instance operations (can be done in parallel)
|
|
instanceDuration := time.Duration(0)
|
|
for _, action := range plan.InstanceActions {
|
|
switch action.Type {
|
|
case ActionCreate:
|
|
instanceDuration = max(instanceDuration, 2*time.Minute)
|
|
case 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)
|
|
} |