diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..912741b --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,294 @@ +// ABOUTME: CLI command for deleting EdgeConnect applications from YAML configuration +// ABOUTME: Removes applications and their instances based on configuration file specification +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + deletev1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/delete/v1" + deletev2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/delete/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "github.com/spf13/cobra" +) + +var ( + deleteConfigFile string + deleteDryRun bool + deleteAutoApprove bool +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete EdgeConnect applications from configuration files", + Long: `Delete EdgeConnect applications and their instances based on YAML configuration files. +This command reads a configuration file, finds matching resources, and deletes them. +Instances are always deleted before the application.`, + Run: func(cmd *cobra.Command, args []string) { + if deleteConfigFile == "" { + fmt.Fprintf(os.Stderr, "Error: configuration file is required\n") + cmd.Usage() + os.Exit(1) + } + + if err := runDelete(deleteConfigFile, deleteDryRun, deleteAutoApprove); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func runDelete(configPath string, isDryRun bool, autoApprove bool) error { + // Step 1: Validate and resolve config file path + absPath, err := filepath.Abs(configPath) + if err != nil { + return fmt.Errorf("failed to resolve config file path: %w", err) + } + + if _, err := os.Stat(absPath); os.IsNotExist(err) { + return fmt.Errorf("configuration file not found: %s", absPath) + } + + fmt.Printf("šŸ“„ Loading configuration from: %s\n", absPath) + + // Step 2: Parse and validate configuration + parser := config.NewParser() + cfg, _, err := parser.ParseFile(absPath) + if err != nil { + return fmt.Errorf("failed to parse configuration: %w", err) + } + + if err := parser.Validate(cfg); err != nil { + return fmt.Errorf("configuration validation failed: %w", err) + } + + fmt.Printf("āœ… Configuration loaded successfully: %s\n", cfg.Metadata.Name) + + // Step 3: Determine API version and create appropriate client + apiVersion := getAPIVersion() + + // Step 4: Execute deletion based on API version + if apiVersion == "v1" { + return runDeleteV1(cfg, isDryRun, autoApprove) + } + return runDeleteV2(cfg, isDryRun, autoApprove) +} + +func runDeleteV1(cfg *config.EdgeConnectConfig, isDryRun bool, autoApprove bool) error { + // Create v1 client + client := newSDKClientV1() + + // Create deletion planner + planner := deletev1.NewPlanner(client) + + // Generate deletion plan + fmt.Println("šŸ” Analyzing current state and generating deletion plan...") + + planOptions := deletev1.DefaultPlanOptions() + planOptions.DryRun = isDryRun + + result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions) + if err != nil { + return fmt.Errorf("failed to generate deletion plan: %w", err) + } + + // Display plan summary + fmt.Println("\nšŸ“‹ Deletion 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 + } + + // Check if there's anything to delete + if result.Plan.TotalActions == 0 { + fmt.Println("\nāœ… No resources found to delete.") + return nil + } + + fmt.Printf("\nThis will delete %d resource(s). Estimated time: %v\n", + result.Plan.TotalActions, result.Plan.EstimatedDuration) + + if !autoApprove && !confirmDeletion() { + fmt.Println("Deletion cancelled.") + return nil + } + + // Execute deletion + fmt.Println("\nšŸ—‘ļø Starting deletion...") + + manager := deletev1.NewResourceManager(client, deletev1.WithLogger(log.Default())) + deleteResult, err := manager.ExecuteDeletion(context.Background(), result.Plan) + if err != nil { + return fmt.Errorf("deletion failed: %w", err) + } + + // Display results + return displayDeletionResults(deleteResult) +} + +func runDeleteV2(cfg *config.EdgeConnectConfig, isDryRun bool, autoApprove bool) error { + // Create v2 client + client := newSDKClientV2() + + // Create deletion planner + planner := deletev2.NewPlanner(client) + + // Generate deletion plan + fmt.Println("šŸ” Analyzing current state and generating deletion plan...") + + planOptions := deletev2.DefaultPlanOptions() + planOptions.DryRun = isDryRun + + result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions) + if err != nil { + return fmt.Errorf("failed to generate deletion plan: %w", err) + } + + // Display plan summary + fmt.Println("\nšŸ“‹ Deletion 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 + } + + // Check if there's anything to delete + if result.Plan.TotalActions == 0 { + fmt.Println("\nāœ… No resources found to delete.") + return nil + } + + fmt.Printf("\nThis will delete %d resource(s). Estimated time: %v\n", + result.Plan.TotalActions, result.Plan.EstimatedDuration) + + if !autoApprove && !confirmDeletion() { + fmt.Println("Deletion cancelled.") + return nil + } + + // Execute deletion + fmt.Println("\nšŸ—‘ļø Starting deletion...") + + manager := deletev2.NewResourceManager(client, deletev2.WithLogger(log.Default())) + deleteResult, err := manager.ExecuteDeletion(context.Background(), result.Plan) + if err != nil { + return fmt.Errorf("deletion failed: %w", err) + } + + // Display results + return displayDeletionResults(deleteResult) +} + +func displayDeletionResults(result interface{}) error { + // Use type assertion to handle both v1 and v2 result types + switch r := result.(type) { + case *deletev1.DeletionResult: + return displayDeletionResultsV1(r) + case *deletev2.DeletionResult: + return displayDeletionResultsV2(r) + default: + return fmt.Errorf("unknown deletion result type") + } +} + +func displayDeletionResultsV1(deleteResult *deletev1.DeletionResult) error { + if deleteResult.Success { + fmt.Printf("\nāœ… Deletion completed successfully in %v\n", deleteResult.Duration) + if len(deleteResult.CompletedActions) > 0 { + fmt.Println("\nDeleted resources:") + for _, action := range deleteResult.CompletedActions { + fmt.Printf(" āœ… %s %s\n", action.Type, action.Target) + } + } + } else { + fmt.Printf("\nāŒ Deletion failed after %v\n", deleteResult.Duration) + if deleteResult.Error != nil { + fmt.Printf("Error: %v\n", deleteResult.Error) + } + if len(deleteResult.FailedActions) > 0 { + fmt.Println("\nFailed actions:") + for _, action := range deleteResult.FailedActions { + fmt.Printf(" āŒ %s %s: %v\n", action.Type, action.Target, action.Error) + } + } + return fmt.Errorf("deletion failed with %d failed actions", len(deleteResult.FailedActions)) + } + return nil +} + +func displayDeletionResultsV2(deleteResult *deletev2.DeletionResult) error { + if deleteResult.Success { + fmt.Printf("\nāœ… Deletion completed successfully in %v\n", deleteResult.Duration) + if len(deleteResult.CompletedActions) > 0 { + fmt.Println("\nDeleted resources:") + for _, action := range deleteResult.CompletedActions { + fmt.Printf(" āœ… %s %s\n", action.Type, action.Target) + } + } + } else { + fmt.Printf("\nāŒ Deletion failed after %v\n", deleteResult.Duration) + if deleteResult.Error != nil { + fmt.Printf("Error: %v\n", deleteResult.Error) + } + if len(deleteResult.FailedActions) > 0 { + fmt.Println("\nFailed actions:") + for _, action := range deleteResult.FailedActions { + fmt.Printf(" āŒ %s %s: %v\n", action.Type, action.Target, action.Error) + } + } + return fmt.Errorf("deletion failed with %d failed actions", len(deleteResult.FailedActions)) + } + return nil +} + +func confirmDeletion() bool { + fmt.Print("Do you want to proceed with deletion? (yes/no): ") + var response string + fmt.Scanln(&response) + + switch response { + case "yes", "y", "YES", "Y": + return true + default: + return false + } +} + +func init() { + rootCmd.AddCommand(deleteCmd) + + deleteCmd.Flags().StringVarP(&deleteConfigFile, "file", "f", "", "configuration file path (required)") + deleteCmd.Flags().BoolVar(&deleteDryRun, "dry-run", false, "preview deletion without actually deleting resources") + deleteCmd.Flags().BoolVar(&deleteAutoApprove, "auto-approve", false, "automatically approve the deletion plan") + + deleteCmd.MarkFlagRequired("file") +} diff --git a/internal/config/example_test.go b/internal/config/example_test.go index dfa3840..536399f 100644 --- a/internal/config/example_test.go +++ b/internal/config/example_test.go @@ -14,7 +14,7 @@ func TestParseExampleConfig(t *testing.T) { parser := NewParser() // Parse the actual example file (now that we've created the manifest file) - examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig.yaml") + examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig_v1.yaml") config, parsedManifest, err := parser.ParseFile(examplePath) // This should now succeed with full validation diff --git a/internal/delete/v1/manager.go b/internal/delete/v1/manager.go new file mode 100644 index 0000000..470ac37 --- /dev/null +++ b/internal/delete/v1/manager.go @@ -0,0 +1,166 @@ +// ABOUTME: Resource management for EdgeConnect delete command with deletion execution +// ABOUTME: Handles actual deletion operations with proper ordering (instances first, then app) +package v1 + +import ( + "context" + "fmt" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" +) + +// ResourceManagerInterface defines the interface for resource management +type ResourceManagerInterface interface { + // ExecuteDeletion executes a deletion plan + ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error) +} + +// EdgeConnectResourceManager implements resource management for EdgeConnect +type EdgeConnectResourceManager struct { + client EdgeConnectClientInterface + logger Logger +} + +// Logger interface for deletion logging +type Logger interface { + Printf(format string, v ...interface{}) +} + +// ResourceManagerOptions configures the resource manager behavior +type ResourceManagerOptions struct { + // Logger for deletion operations + Logger Logger +} + +// DefaultResourceManagerOptions returns sensible defaults +func DefaultResourceManagerOptions() ResourceManagerOptions { + return ResourceManagerOptions{ + Logger: nil, + } +} + +// 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, + logger: options.Logger, + } +} + +// WithLogger sets a logger for deletion operations +func WithLogger(logger Logger) func(*ResourceManagerOptions) { + return func(opts *ResourceManagerOptions) { + opts.Logger = logger + } +} + +// ExecuteDeletion executes a deletion plan +// Important: Instances must be deleted before the app +func (rm *EdgeConnectResourceManager) ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error) { + startTime := time.Now() + rm.logf("Starting deletion: %s", plan.ConfigName) + + result := &DeletionResult{ + Plan: plan, + Success: true, + CompletedActions: []DeletionActionResult{}, + FailedActions: []DeletionActionResult{}, + } + + // If plan is empty, return success immediately + if plan.IsEmpty() { + rm.logf("No resources to delete") + result.Duration = time.Since(startTime) + return result, nil + } + + // Step 1: Delete all instances first + for _, instance := range plan.InstancesToDelete { + actionStart := time.Now() + rm.logf("Deleting instance: %s", instance.Name) + + instanceKey := edgeconnect.AppInstanceKey{ + Organization: instance.Organization, + Name: instance.Name, + CloudletKey: edgeconnect.CloudletKey{ + Organization: instance.CloudletOrg, + Name: instance.CloudletName, + }, + } + + err := rm.client.DeleteAppInstance(ctx, instanceKey, instance.Region) + actionResult := DeletionActionResult{ + Type: "instance", + Target: instance.Name, + Duration: time.Since(actionStart), + } + + if err != nil { + rm.logf("Failed to delete instance %s: %v", instance.Name, err) + actionResult.Success = false + actionResult.Error = err + result.FailedActions = append(result.FailedActions, actionResult) + result.Success = false + result.Error = fmt.Errorf("failed to delete instance %s: %w", instance.Name, err) + result.Duration = time.Since(startTime) + return result, result.Error + } + + rm.logf("Successfully deleted instance: %s", instance.Name) + actionResult.Success = true + result.CompletedActions = append(result.CompletedActions, actionResult) + } + + // Step 2: Delete the app (only after all instances are deleted) + if plan.AppToDelete != nil { + actionStart := time.Now() + app := plan.AppToDelete + rm.logf("Deleting app: %s version %s", app.Name, app.Version) + + appKey := edgeconnect.AppKey{ + Organization: app.Organization, + Name: app.Name, + Version: app.Version, + } + + err := rm.client.DeleteApp(ctx, appKey, app.Region) + actionResult := DeletionActionResult{ + Type: "app", + Target: fmt.Sprintf("%s:%s", app.Name, app.Version), + Duration: time.Since(actionStart), + } + + if err != nil { + rm.logf("Failed to delete app %s: %v", app.Name, err) + actionResult.Success = false + actionResult.Error = err + result.FailedActions = append(result.FailedActions, actionResult) + result.Success = false + result.Error = fmt.Errorf("failed to delete app %s: %w", app.Name, err) + result.Duration = time.Since(startTime) + return result, result.Error + } + + rm.logf("Successfully deleted app: %s", app.Name) + actionResult.Success = true + result.CompletedActions = append(result.CompletedActions, actionResult) + } + + result.Duration = time.Since(startTime) + rm.logf("Deletion completed successfully in %v", result.Duration) + + return result, nil +} + +// logf logs a message if a logger is configured +func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) { + if rm.logger != nil { + rm.logger.Printf(format, v...) + } +} diff --git a/internal/delete/v1/planner.go b/internal/delete/v1/planner.go new file mode 100644 index 0000000..d436057 --- /dev/null +++ b/internal/delete/v1/planner.go @@ -0,0 +1,228 @@ +// ABOUTME: Deletion planner for EdgeConnect delete command +// ABOUTME: Analyzes current state to identify resources for deletion +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" +) + +// EdgeConnectClientInterface defines the methods needed for deletion planning +type EdgeConnectClientInterface interface { + ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) + ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) + DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error + DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error +} + +// Planner defines the interface for deletion planning +type Planner interface { + // Plan analyzes the configuration and current state to generate a deletion 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 + + // Timeout for API operations + Timeout time.Duration +} + +// DefaultPlanOptions returns sensible default planning options +func DefaultPlanOptions() PlanOptions { + return PlanOptions{ + DryRun: false, + Timeout: 30 * time.Second, + } +} + +// EdgeConnectPlanner implements the Planner interface for EdgeConnect +type EdgeConnectPlanner struct { + client EdgeConnectClientInterface +} + +// NewPlanner creates a new EdgeConnect deletion planner +func NewPlanner(client EdgeConnectClientInterface) Planner { + return &EdgeConnectPlanner{ + client: client, + } +} + +// Plan analyzes the configuration and generates a deletion plan +func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) { + return p.PlanWithOptions(ctx, config, DefaultPlanOptions()) +} + +// PlanWithOptions generates a deletion 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 deletion plan structure + plan := &DeletionPlan{ + ConfigName: config.Metadata.Name, + CreatedAt: startTime, + DryRun: opts.DryRun, + } + + // Get the region from the first infra template + region := config.Spec.InfraTemplate[0].Region + + // Step 1: Check if instances exist + instancesResult := p.findInstancesToDelete(ctx, config, region) + plan.InstancesToDelete = instancesResult.instances + if instancesResult.err != nil { + warnings = append(warnings, fmt.Sprintf("Error querying instances: %v", instancesResult.err)) + } + + // Step 2: Check if app exists + appResult := p.findAppToDelete(ctx, config, region) + plan.AppToDelete = appResult.app + if appResult.err != nil && !isNotFoundError(appResult.err) { + warnings = append(warnings, fmt.Sprintf("Error querying app: %v", appResult.err)) + } + + // Step 3: Calculate plan metadata + p.calculatePlanMetadata(plan) + + // Step 4: Generate summary + plan.Summary = plan.GenerateSummary() + + return &PlanResult{ + Plan: plan, + Warnings: warnings, + }, nil +} + +type appQueryResult struct { + app *AppDeletion + err error +} + +type instancesQueryResult struct { + instances []InstanceDeletion + err error +} + +// findAppToDelete checks if the app exists and should be deleted +func (p *EdgeConnectPlanner) findAppToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) appQueryResult { + appKey := edgeconnect.AppKey{ + Organization: config.Metadata.Organization, + Name: config.Metadata.Name, + Version: config.Metadata.AppVersion, + } + + app, err := p.client.ShowApp(ctx, appKey, region) + if err != nil { + if isNotFoundError(err) { + return appQueryResult{app: nil, err: nil} + } + return appQueryResult{app: nil, err: err} + } + + return appQueryResult{ + app: &AppDeletion{ + Name: app.Key.Name, + Version: app.Key.Version, + Organization: app.Key.Organization, + Region: region, + }, + err: nil, + } +} + +// findInstancesToDelete finds all instances that match the config +func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) instancesQueryResult { + var allInstances []InstanceDeletion + + // Query instances for each infra template + for _, infra := range config.Spec.InfraTemplate { + instanceKey := edgeconnect.AppInstanceKey{ + Organization: config.Metadata.Organization, + Name: generateInstanceName(config.Metadata.Name, config.Metadata.AppVersion), + CloudletKey: edgeconnect.CloudletKey{ + Organization: infra.CloudletOrg, + Name: infra.CloudletName, + }, + } + + instances, err := p.client.ShowAppInstances(ctx, instanceKey, infra.Region) + if err != nil { + // If it's a not found error, just continue + if isNotFoundError(err) { + continue + } + return instancesQueryResult{instances: nil, err: err} + } + + // Add found instances to the list + for _, inst := range instances { + allInstances = append(allInstances, InstanceDeletion{ + Name: inst.Key.Name, + Organization: inst.Key.Organization, + Region: infra.Region, + CloudletOrg: inst.Key.CloudletKey.Organization, + CloudletName: inst.Key.CloudletKey.Name, + }) + } + } + + return instancesQueryResult{ + instances: allInstances, + err: nil, + } +} + +// calculatePlanMetadata calculates the total actions and estimated duration +func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeletionPlan) { + totalActions := 0 + + if plan.AppToDelete != nil { + totalActions++ + } + + totalActions += len(plan.InstancesToDelete) + + plan.TotalActions = totalActions + + // Estimate duration: ~5 seconds per instance, ~3 seconds for app + estimatedSeconds := len(plan.InstancesToDelete) * 5 + if plan.AppToDelete != nil { + estimatedSeconds += 3 + } + plan.EstimatedDuration = time.Duration(estimatedSeconds) * time.Second +} + +// generateInstanceName creates an instance name from app name and version +func generateInstanceName(appName, appVersion string) string { + return fmt.Sprintf("%s-%s-instance", appName, appVersion) +} + +// isNotFoundError checks if an error is a 404 not found error +func isNotFoundError(err error) bool { + if apiErr, ok := err.(*edgeconnect.APIError); ok { + return apiErr.StatusCode == 404 + } + return false +} + +// PlanResult represents the result of a deletion planning operation +type PlanResult struct { + // Plan is the generated deletion plan + Plan *DeletionPlan + + // Error if planning failed + Error error + + // Warnings encountered during planning + Warnings []string +} diff --git a/internal/delete/v1/types.go b/internal/delete/v1/types.go new file mode 100644 index 0000000..a4d491c --- /dev/null +++ b/internal/delete/v1/types.go @@ -0,0 +1,157 @@ +// ABOUTME: Deletion planning types for EdgeConnect delete command +// ABOUTME: Defines structures for deletion plans and deletion results +package v1 + +import ( + "fmt" + "strings" + "time" +) + +// DeletionPlan represents the complete deletion plan for a configuration +type DeletionPlan struct { + // ConfigName is the name from metadata + ConfigName string + + // AppToDelete defines the app that will be deleted (nil if app doesn't exist) + AppToDelete *AppDeletion + + // InstancesToDelete defines the instances that will be deleted + InstancesToDelete []InstanceDeletion + + // 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 deletion + EstimatedDuration time.Duration + + // CreatedAt timestamp when the plan was created + CreatedAt time.Time + + // DryRun indicates if this is a dry-run plan + DryRun bool +} + +// AppDeletion represents an application to be deleted +type AppDeletion 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 +} + +// InstanceDeletion represents an application instance to be deleted +type InstanceDeletion struct { + // Name of the instance + Name 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 +} + +// DeletionResult represents the result of a deletion operation +type DeletionResult struct { + // Plan that was executed + Plan *DeletionPlan + + // Success indicates if the deletion was successful + Success bool + + // CompletedActions lists actions that were successfully completed + CompletedActions []DeletionActionResult + + // FailedActions lists actions that failed + FailedActions []DeletionActionResult + + // Error that caused the deletion to fail (if any) + Error error + + // Duration taken to execute the plan + Duration time.Duration +} + +// DeletionActionResult represents the result of executing a single deletion action +type DeletionActionResult struct { + // Type of resource that was deleted ("app" or "instance") + Type string + + // Target describes what was being deleted + 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 +} + +// IsEmpty returns true if the deletion plan has no actions to perform +func (dp *DeletionPlan) IsEmpty() bool { + return dp.AppToDelete == nil && len(dp.InstancesToDelete) == 0 +} + +// GenerateSummary creates a human-readable summary of the deletion plan +func (dp *DeletionPlan) GenerateSummary() string { + if dp.IsEmpty() { + return "No resources found to delete" + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Deletion plan for '%s':\n", dp.ConfigName)) + + // Instance actions + if len(dp.InstancesToDelete) > 0 { + sb.WriteString(fmt.Sprintf("- DELETE %d instance(s)\n", len(dp.InstancesToDelete))) + cloudletSet := make(map[string]bool) + for _, inst := range dp.InstancesToDelete { + key := fmt.Sprintf("%s:%s", inst.CloudletOrg, inst.CloudletName) + cloudletSet[key] = true + } + sb.WriteString(fmt.Sprintf(" Across %d cloudlet(s)\n", len(cloudletSet))) + } + + // App action + if dp.AppToDelete != nil { + sb.WriteString(fmt.Sprintf("- DELETE application '%s' version %s\n", + dp.AppToDelete.Name, dp.AppToDelete.Version)) + } + + sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String())) + + return sb.String() +} + +// Validate checks if the deletion plan is valid +func (dp *DeletionPlan) Validate() error { + if dp.ConfigName == "" { + return fmt.Errorf("deletion plan must have a config name") + } + + if dp.IsEmpty() { + return fmt.Errorf("deletion plan has no resources to delete") + } + + return nil +} diff --git a/internal/delete/v2/manager.go b/internal/delete/v2/manager.go new file mode 100644 index 0000000..a644f32 --- /dev/null +++ b/internal/delete/v2/manager.go @@ -0,0 +1,166 @@ +// ABOUTME: Resource management for EdgeConnect delete command with deletion execution +// ABOUTME: Handles actual deletion operations with proper ordering (instances first, then app) +package v2 + +import ( + "context" + "fmt" + "time" + + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" +) + +// ResourceManagerInterface defines the interface for resource management +type ResourceManagerInterface interface { + // ExecuteDeletion executes a deletion plan + ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error) +} + +// EdgeConnectResourceManager implements resource management for EdgeConnect +type EdgeConnectResourceManager struct { + client EdgeConnectClientInterface + logger Logger +} + +// Logger interface for deletion logging +type Logger interface { + Printf(format string, v ...interface{}) +} + +// ResourceManagerOptions configures the resource manager behavior +type ResourceManagerOptions struct { + // Logger for deletion operations + Logger Logger +} + +// DefaultResourceManagerOptions returns sensible defaults +func DefaultResourceManagerOptions() ResourceManagerOptions { + return ResourceManagerOptions{ + Logger: nil, + } +} + +// 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, + logger: options.Logger, + } +} + +// WithLogger sets a logger for deletion operations +func WithLogger(logger Logger) func(*ResourceManagerOptions) { + return func(opts *ResourceManagerOptions) { + opts.Logger = logger + } +} + +// ExecuteDeletion executes a deletion plan +// Important: Instances must be deleted before the app +func (rm *EdgeConnectResourceManager) ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error) { + startTime := time.Now() + rm.logf("Starting deletion: %s", plan.ConfigName) + + result := &DeletionResult{ + Plan: plan, + Success: true, + CompletedActions: []DeletionActionResult{}, + FailedActions: []DeletionActionResult{}, + } + + // If plan is empty, return success immediately + if plan.IsEmpty() { + rm.logf("No resources to delete") + result.Duration = time.Since(startTime) + return result, nil + } + + // Step 1: Delete all instances first + for _, instance := range plan.InstancesToDelete { + actionStart := time.Now() + rm.logf("Deleting instance: %s", instance.Name) + + instanceKey := v2.AppInstanceKey{ + Organization: instance.Organization, + Name: instance.Name, + CloudletKey: v2.CloudletKey{ + Organization: instance.CloudletOrg, + Name: instance.CloudletName, + }, + } + + err := rm.client.DeleteAppInstance(ctx, instanceKey, instance.Region) + actionResult := DeletionActionResult{ + Type: "instance", + Target: instance.Name, + Duration: time.Since(actionStart), + } + + if err != nil { + rm.logf("Failed to delete instance %s: %v", instance.Name, err) + actionResult.Success = false + actionResult.Error = err + result.FailedActions = append(result.FailedActions, actionResult) + result.Success = false + result.Error = fmt.Errorf("failed to delete instance %s: %w", instance.Name, err) + result.Duration = time.Since(startTime) + return result, result.Error + } + + rm.logf("Successfully deleted instance: %s", instance.Name) + actionResult.Success = true + result.CompletedActions = append(result.CompletedActions, actionResult) + } + + // Step 2: Delete the app (only after all instances are deleted) + if plan.AppToDelete != nil { + actionStart := time.Now() + app := plan.AppToDelete + rm.logf("Deleting app: %s version %s", app.Name, app.Version) + + appKey := v2.AppKey{ + Organization: app.Organization, + Name: app.Name, + Version: app.Version, + } + + err := rm.client.DeleteApp(ctx, appKey, app.Region) + actionResult := DeletionActionResult{ + Type: "app", + Target: fmt.Sprintf("%s:%s", app.Name, app.Version), + Duration: time.Since(actionStart), + } + + if err != nil { + rm.logf("Failed to delete app %s: %v", app.Name, err) + actionResult.Success = false + actionResult.Error = err + result.FailedActions = append(result.FailedActions, actionResult) + result.Success = false + result.Error = fmt.Errorf("failed to delete app %s: %w", app.Name, err) + result.Duration = time.Since(startTime) + return result, result.Error + } + + rm.logf("Successfully deleted app: %s", app.Name) + actionResult.Success = true + result.CompletedActions = append(result.CompletedActions, actionResult) + } + + result.Duration = time.Since(startTime) + rm.logf("Deletion completed successfully in %v", result.Duration) + + return result, nil +} + +// logf logs a message if a logger is configured +func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) { + if rm.logger != nil { + rm.logger.Printf(format, v...) + } +} diff --git a/internal/delete/v2/manager_test.go b/internal/delete/v2/manager_test.go new file mode 100644 index 0000000..fd098af --- /dev/null +++ b/internal/delete/v2/manager_test.go @@ -0,0 +1,200 @@ +// ABOUTME: Tests for EdgeConnect deletion manager with mock scenarios +// ABOUTME: Tests deletion execution and error handling with mock clients +package v2 + +import ( + "context" + "fmt" + "testing" + "time" + + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// MockResourceClient for testing deletion manager +type MockResourceClient struct { + mock.Mock +} + +func (m *MockResourceClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) { + args := m.Called(ctx, appKey, region) + if args.Get(0) == nil { + return v2.App{}, args.Error(1) + } + return args.Get(0).(v2.App), args.Error(1) +} + +func (m *MockResourceClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) { + args := m.Called(ctx, instanceKey, region) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]v2.AppInstance), args.Error(1) +} + +func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error { + args := m.Called(ctx, appKey, region) + return args.Error(0) +} + +func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey v2.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) +} + +func TestWithLogger(t *testing.T) { + mockClient := &MockResourceClient{} + logger := &TestLogger{} + + manager := NewResourceManager(mockClient, WithLogger(logger)) + + // Cast to implementation to check logger was set + impl := manager.(*EdgeConnectResourceManager) + assert.Equal(t, logger, impl.logger) +} + +func createTestDeletionPlan() *DeletionPlan { + return &DeletionPlan{ + ConfigName: "test-deletion", + AppToDelete: &AppDeletion{ + Name: "test-app", + Version: "1.0.0", + Organization: "testorg", + Region: "US", + }, + InstancesToDelete: []InstanceDeletion{ + { + Name: "test-app-1.0.0-instance", + Organization: "testorg", + Region: "US", + CloudletOrg: "cloudletorg", + CloudletName: "cloudlet1", + }, + }, + TotalActions: 2, + EstimatedDuration: 10 * time.Second, + } +} + +func TestExecuteDeletion_Success(t *testing.T) { + mockClient := &MockResourceClient{} + logger := &TestLogger{} + manager := NewResourceManager(mockClient, WithLogger(logger)) + + plan := createTestDeletionPlan() + + // Mock successful deletion operations + mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US"). + Return(nil) + mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). + Return(nil) + + ctx := context.Background() + result, err := manager.ExecuteDeletion(ctx, plan) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Success) + assert.Len(t, result.CompletedActions, 2) // 1 instance + 1 app + assert.Len(t, result.FailedActions, 0) + + mockClient.AssertExpectations(t) +} + +func TestExecuteDeletion_InstanceDeleteFails(t *testing.T) { + mockClient := &MockResourceClient{} + logger := &TestLogger{} + manager := NewResourceManager(mockClient, WithLogger(logger)) + + plan := createTestDeletionPlan() + + // Mock instance deletion failure + mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US"). + Return(fmt.Errorf("instance deletion failed")) + + ctx := context.Background() + result, err := manager.ExecuteDeletion(ctx, plan) + + require.Error(t, err) + require.NotNil(t, result) + assert.False(t, result.Success) + assert.Len(t, result.FailedActions, 1) + + mockClient.AssertExpectations(t) +} + +func TestExecuteDeletion_OnlyInstances(t *testing.T) { + mockClient := &MockResourceClient{} + logger := &TestLogger{} + manager := NewResourceManager(mockClient, WithLogger(logger)) + + plan := &DeletionPlan{ + ConfigName: "test-deletion", + AppToDelete: nil, // No app to delete + InstancesToDelete: []InstanceDeletion{ + { + Name: "test-app-1.0.0-instance", + Organization: "testorg", + Region: "US", + CloudletOrg: "cloudletorg", + CloudletName: "cloudlet1", + }, + }, + TotalActions: 1, + EstimatedDuration: 5 * time.Second, + } + + // Mock successful instance deletion + mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US"). + Return(nil) + + ctx := context.Background() + result, err := manager.ExecuteDeletion(ctx, plan) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Success) + assert.Len(t, result.CompletedActions, 1) + + mockClient.AssertExpectations(t) +} + +func TestExecuteDeletion_EmptyPlan(t *testing.T) { + mockClient := &MockResourceClient{} + manager := NewResourceManager(mockClient) + + plan := &DeletionPlan{ + ConfigName: "test-deletion", + AppToDelete: nil, + InstancesToDelete: []InstanceDeletion{}, + TotalActions: 0, + } + + ctx := context.Background() + result, err := manager.ExecuteDeletion(ctx, plan) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Success) + assert.Len(t, result.CompletedActions, 0) + assert.Len(t, result.FailedActions, 0) +} diff --git a/internal/delete/v2/planner.go b/internal/delete/v2/planner.go new file mode 100644 index 0000000..e77cd9e --- /dev/null +++ b/internal/delete/v2/planner.go @@ -0,0 +1,228 @@ +// ABOUTME: Deletion planner for EdgeConnect delete command +// ABOUTME: Analyzes current state to identify resources for deletion +package v2 + +import ( + "context" + "fmt" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" +) + +// EdgeConnectClientInterface defines the methods needed for deletion planning +type EdgeConnectClientInterface interface { + ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) + ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) + DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error + DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error +} + +// Planner defines the interface for deletion planning +type Planner interface { + // Plan analyzes the configuration and current state to generate a deletion 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 + + // Timeout for API operations + Timeout time.Duration +} + +// DefaultPlanOptions returns sensible default planning options +func DefaultPlanOptions() PlanOptions { + return PlanOptions{ + DryRun: false, + Timeout: 30 * time.Second, + } +} + +// EdgeConnectPlanner implements the Planner interface for EdgeConnect +type EdgeConnectPlanner struct { + client EdgeConnectClientInterface +} + +// NewPlanner creates a new EdgeConnect deletion planner +func NewPlanner(client EdgeConnectClientInterface) Planner { + return &EdgeConnectPlanner{ + client: client, + } +} + +// Plan analyzes the configuration and generates a deletion plan +func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) { + return p.PlanWithOptions(ctx, config, DefaultPlanOptions()) +} + +// PlanWithOptions generates a deletion 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 deletion plan structure + plan := &DeletionPlan{ + ConfigName: config.Metadata.Name, + CreatedAt: startTime, + DryRun: opts.DryRun, + } + + // Get the region from the first infra template + region := config.Spec.InfraTemplate[0].Region + + // Step 1: Check if instances exist + instancesResult := p.findInstancesToDelete(ctx, config, region) + plan.InstancesToDelete = instancesResult.instances + if instancesResult.err != nil { + warnings = append(warnings, fmt.Sprintf("Error querying instances: %v", instancesResult.err)) + } + + // Step 2: Check if app exists + appResult := p.findAppToDelete(ctx, config, region) + plan.AppToDelete = appResult.app + if appResult.err != nil && !isNotFoundError(appResult.err) { + warnings = append(warnings, fmt.Sprintf("Error querying app: %v", appResult.err)) + } + + // Step 3: Calculate plan metadata + p.calculatePlanMetadata(plan) + + // Step 4: Generate summary + plan.Summary = plan.GenerateSummary() + + return &PlanResult{ + Plan: plan, + Warnings: warnings, + }, nil +} + +type appQueryResult struct { + app *AppDeletion + err error +} + +type instancesQueryResult struct { + instances []InstanceDeletion + err error +} + +// findAppToDelete checks if the app exists and should be deleted +func (p *EdgeConnectPlanner) findAppToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) appQueryResult { + appKey := v2.AppKey{ + Organization: config.Metadata.Organization, + Name: config.Metadata.Name, + Version: config.Metadata.AppVersion, + } + + app, err := p.client.ShowApp(ctx, appKey, region) + if err != nil { + if isNotFoundError(err) { + return appQueryResult{app: nil, err: nil} + } + return appQueryResult{app: nil, err: err} + } + + return appQueryResult{ + app: &AppDeletion{ + Name: app.Key.Name, + Version: app.Key.Version, + Organization: app.Key.Organization, + Region: region, + }, + err: nil, + } +} + +// findInstancesToDelete finds all instances that match the config +func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) instancesQueryResult { + var allInstances []InstanceDeletion + + // Query instances for each infra template + for _, infra := range config.Spec.InfraTemplate { + instanceKey := v2.AppInstanceKey{ + Organization: config.Metadata.Organization, + Name: generateInstanceName(config.Metadata.Name, config.Metadata.AppVersion), + CloudletKey: v2.CloudletKey{ + Organization: infra.CloudletOrg, + Name: infra.CloudletName, + }, + } + + instances, err := p.client.ShowAppInstances(ctx, instanceKey, infra.Region) + if err != nil { + // If it's a not found error, just continue + if isNotFoundError(err) { + continue + } + return instancesQueryResult{instances: nil, err: err} + } + + // Add found instances to the list + for _, inst := range instances { + allInstances = append(allInstances, InstanceDeletion{ + Name: inst.Key.Name, + Organization: inst.Key.Organization, + Region: infra.Region, + CloudletOrg: inst.Key.CloudletKey.Organization, + CloudletName: inst.Key.CloudletKey.Name, + }) + } + } + + return instancesQueryResult{ + instances: allInstances, + err: nil, + } +} + +// calculatePlanMetadata calculates the total actions and estimated duration +func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeletionPlan) { + totalActions := 0 + + if plan.AppToDelete != nil { + totalActions++ + } + + totalActions += len(plan.InstancesToDelete) + + plan.TotalActions = totalActions + + // Estimate duration: ~5 seconds per instance, ~3 seconds for app + estimatedSeconds := len(plan.InstancesToDelete) * 5 + if plan.AppToDelete != nil { + estimatedSeconds += 3 + } + plan.EstimatedDuration = time.Duration(estimatedSeconds) * time.Second +} + +// generateInstanceName creates an instance name from app name and version +func generateInstanceName(appName, appVersion string) string { + return fmt.Sprintf("%s-%s-instance", appName, appVersion) +} + +// isNotFoundError checks if an error is a 404 not found error +func isNotFoundError(err error) bool { + if apiErr, ok := err.(*v2.APIError); ok { + return apiErr.StatusCode == 404 + } + return false +} + +// PlanResult represents the result of a deletion planning operation +type PlanResult struct { + // Plan is the generated deletion plan + Plan *DeletionPlan + + // Error if planning failed + Error error + + // Warnings encountered during planning + Warnings []string +} diff --git a/internal/delete/v2/planner_test.go b/internal/delete/v2/planner_test.go new file mode 100644 index 0000000..c37a318 --- /dev/null +++ b/internal/delete/v2/planner_test.go @@ -0,0 +1,219 @@ +// ABOUTME: Tests for EdgeConnect deletion planner with mock scenarios +// ABOUTME: Tests deletion planning logic and resource discovery +package v2 + +import ( + "context" + "os" + "path/filepath" + "testing" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "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 v2.AppKey, region string) (v2.App, error) { + args := m.Called(ctx, appKey, region) + if args.Get(0) == nil { + return v2.App{}, args.Error(1) + } + return args.Get(0).(v2.App), args.Error(1) +} + +func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) { + args := m.Called(ctx, instanceKey, region) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]v2.AppInstance), args.Error(1) +} + +func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error { + args := m.Called(ctx, appKey, region) + return args.Error(0) +} + +func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error { + args := m.Called(ctx, instanceKey, region) + return args.Error(0) +} + +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", + }, + }, + }, + } +} + +func TestNewPlanner(t *testing.T) { + mockClient := &MockEdgeConnectClient{} + planner := NewPlanner(mockClient) + + assert.NotNil(t, planner) +} + +func TestPlanDeletion_WithExistingResources(t *testing.T) { + mockClient := &MockEdgeConnectClient{} + planner := NewPlanner(mockClient) + testConfig := createTestConfig(t) + + // Mock existing app + existingApp := v2.App{ + Key: v2.AppKey{ + Organization: "testorg", + Name: "test-app", + Version: "1.0.0", + }, + Deployment: "kubernetes", + } + + // Mock existing instances + existingInstances := []v2.AppInstance{ + { + Key: v2.AppInstanceKey{ + Organization: "testorg", + Name: "test-app-1.0.0-instance", + CloudletKey: v2.CloudletKey{ + Organization: "TestCloudletOrg", + Name: "TestCloudlet", + }, + }, + AppKey: v2.AppKey{ + Organization: "testorg", + Name: "test-app", + Version: "1.0.0", + }, + }, + } + + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). + Return(existingApp, nil) + + mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US"). + Return(existingInstances, 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, "test-app", plan.ConfigName) + assert.NotNil(t, plan.AppToDelete) + assert.Equal(t, "test-app", plan.AppToDelete.Name) + assert.Equal(t, "1.0.0", plan.AppToDelete.Version) + assert.Equal(t, "testorg", plan.AppToDelete.Organization) + + require.Len(t, plan.InstancesToDelete, 1) + assert.Equal(t, "test-app-1.0.0-instance", plan.InstancesToDelete[0].Name) + assert.Equal(t, "testorg", plan.InstancesToDelete[0].Organization) + + assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance + assert.False(t, plan.IsEmpty()) + + mockClient.AssertExpectations(t) +} + +func TestPlanDeletion_NoResourcesExist(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("v2.AppKey"), "US"). + Return(v2.App{}, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}}) + + mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US"). + Return([]v2.AppInstance{}, 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, "test-app", plan.ConfigName) + assert.Nil(t, plan.AppToDelete) + assert.Len(t, plan.InstancesToDelete, 0) + assert.Equal(t, 0, plan.TotalActions) + assert.True(t, plan.IsEmpty()) + + mockClient.AssertExpectations(t) +} + +func TestPlanDeletion_OnlyInstancesExist(t *testing.T) { + mockClient := &MockEdgeConnectClient{} + planner := NewPlanner(mockClient) + testConfig := createTestConfig(t) + + // Mock existing instances but no app + existingInstances := []v2.AppInstance{ + { + Key: v2.AppInstanceKey{ + Organization: "testorg", + Name: "test-app-1.0.0-instance", + CloudletKey: v2.CloudletKey{ + Organization: "TestCloudletOrg", + Name: "TestCloudlet", + }, + }, + }, + } + + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). + Return(v2.App{}, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}}) + + mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US"). + Return(existingInstances, 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.Nil(t, plan.AppToDelete) + assert.Len(t, plan.InstancesToDelete, 1) + assert.Equal(t, 1, plan.TotalActions) + assert.False(t, plan.IsEmpty()) + + mockClient.AssertExpectations(t) +} diff --git a/internal/delete/v2/types.go b/internal/delete/v2/types.go new file mode 100644 index 0000000..de50a68 --- /dev/null +++ b/internal/delete/v2/types.go @@ -0,0 +1,157 @@ +// ABOUTME: Deletion planning types for EdgeConnect delete command +// ABOUTME: Defines structures for deletion plans and deletion results +package v2 + +import ( + "fmt" + "strings" + "time" +) + +// DeletionPlan represents the complete deletion plan for a configuration +type DeletionPlan struct { + // ConfigName is the name from metadata + ConfigName string + + // AppToDelete defines the app that will be deleted (nil if app doesn't exist) + AppToDelete *AppDeletion + + // InstancesToDelete defines the instances that will be deleted + InstancesToDelete []InstanceDeletion + + // 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 deletion + EstimatedDuration time.Duration + + // CreatedAt timestamp when the plan was created + CreatedAt time.Time + + // DryRun indicates if this is a dry-run plan + DryRun bool +} + +// AppDeletion represents an application to be deleted +type AppDeletion 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 +} + +// InstanceDeletion represents an application instance to be deleted +type InstanceDeletion struct { + // Name of the instance + Name 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 +} + +// DeletionResult represents the result of a deletion operation +type DeletionResult struct { + // Plan that was executed + Plan *DeletionPlan + + // Success indicates if the deletion was successful + Success bool + + // CompletedActions lists actions that were successfully completed + CompletedActions []DeletionActionResult + + // FailedActions lists actions that failed + FailedActions []DeletionActionResult + + // Error that caused the deletion to fail (if any) + Error error + + // Duration taken to execute the plan + Duration time.Duration +} + +// DeletionActionResult represents the result of executing a single deletion action +type DeletionActionResult struct { + // Type of resource that was deleted ("app" or "instance") + Type string + + // Target describes what was being deleted + 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 +} + +// IsEmpty returns true if the deletion plan has no actions to perform +func (dp *DeletionPlan) IsEmpty() bool { + return dp.AppToDelete == nil && len(dp.InstancesToDelete) == 0 +} + +// GenerateSummary creates a human-readable summary of the deletion plan +func (dp *DeletionPlan) GenerateSummary() string { + if dp.IsEmpty() { + return "No resources found to delete" + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Deletion plan for '%s':\n", dp.ConfigName)) + + // Instance actions + if len(dp.InstancesToDelete) > 0 { + sb.WriteString(fmt.Sprintf("- DELETE %d instance(s)\n", len(dp.InstancesToDelete))) + cloudletSet := make(map[string]bool) + for _, inst := range dp.InstancesToDelete { + key := fmt.Sprintf("%s:%s", inst.CloudletOrg, inst.CloudletName) + cloudletSet[key] = true + } + sb.WriteString(fmt.Sprintf(" Across %d cloudlet(s)\n", len(cloudletSet))) + } + + // App action + if dp.AppToDelete != nil { + sb.WriteString(fmt.Sprintf("- DELETE application '%s' version %s\n", + dp.AppToDelete.Name, dp.AppToDelete.Version)) + } + + sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String())) + + return sb.String() +} + +// Validate checks if the deletion plan is valid +func (dp *DeletionPlan) Validate() error { + if dp.ConfigName == "" { + return fmt.Errorf("deletion plan must have a config name") + } + + if dp.IsEmpty() { + return fmt.Errorf("deletion plan has no resources to delete") + } + + return nil +} diff --git a/internal/delete/v2/types_test.go b/internal/delete/v2/types_test.go new file mode 100644 index 0000000..8dfa6b0 --- /dev/null +++ b/internal/delete/v2/types_test.go @@ -0,0 +1,95 @@ +package v2 + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDeletionPlan_IsEmpty(t *testing.T) { + tests := []struct { + name string + plan *DeletionPlan + expected bool + }{ + { + name: "empty plan with no resources", + plan: &DeletionPlan{ + ConfigName: "test-config", + AppToDelete: nil, + InstancesToDelete: []InstanceDeletion{}, + }, + expected: true, + }, + { + name: "plan with app deletion", + plan: &DeletionPlan{ + ConfigName: "test-config", + AppToDelete: &AppDeletion{ + Name: "test-app", + Organization: "test-org", + Version: "1.0", + Region: "US", + }, + InstancesToDelete: []InstanceDeletion{}, + }, + expected: false, + }, + { + name: "plan with instance deletion", + plan: &DeletionPlan{ + ConfigName: "test-config", + AppToDelete: nil, + InstancesToDelete: []InstanceDeletion{ + { + Name: "test-instance", + Organization: "test-org", + }, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.plan.IsEmpty() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestDeletionPlan_GenerateSummary(t *testing.T) { + plan := &DeletionPlan{ + ConfigName: "test-config", + AppToDelete: &AppDeletion{ + Name: "test-app", + Organization: "test-org", + Version: "1.0", + Region: "US", + }, + InstancesToDelete: []InstanceDeletion{ + { + Name: "test-instance-1", + Organization: "test-org", + CloudletName: "cloudlet-1", + CloudletOrg: "cloudlet-org", + }, + { + Name: "test-instance-2", + Organization: "test-org", + CloudletName: "cloudlet-2", + CloudletOrg: "cloudlet-org", + }, + }, + TotalActions: 3, + EstimatedDuration: 30 * time.Second, + } + + summary := plan.GenerateSummary() + + assert.Contains(t, summary, "test-config") + assert.Contains(t, summary, "DELETE application 'test-app'") + assert.Contains(t, summary, "DELETE 2 instance(s)") +} diff --git a/sdk/edgeconnect/v2/appinstance.go b/sdk/edgeconnect/v2/appinstance.go index 57e6b3c..4fb7204 100644 --- a/sdk/edgeconnect/v2/appinstance.go +++ b/sdk/edgeconnect/v2/appinstance.go @@ -173,8 +173,9 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst" input := DeleteAppInstanceInput{ - Key: appInstKey, + Region: region, } + input.AppInst.Key = appInstKey resp, err := transport.Call(ctx, "POST", url, input) if err != nil { diff --git a/sdk/edgeconnect/v2/apps.go b/sdk/edgeconnect/v2/apps.go index ce5bb76..06d529f 100644 --- a/sdk/edgeconnect/v2/apps.go +++ b/sdk/edgeconnect/v2/apps.go @@ -144,9 +144,9 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp" input := DeleteAppInput{ - Key: appKey, Region: region, } + input.App.Key = appKey resp, err := transport.Call(ctx, "POST", url, input) if err != nil { diff --git a/sdk/edgeconnect/v2/types.go b/sdk/edgeconnect/v2/types.go index 82995e0..0bb6875 100644 --- a/sdk/edgeconnect/v2/types.go +++ b/sdk/edgeconnect/v2/types.go @@ -273,13 +273,18 @@ type UpdateAppInstanceInput struct { // DeleteAppInput represents input for deleting an application type DeleteAppInput struct { - Key AppKey `json:"key"` Region string `json:"region"` + App struct { + Key AppKey `json:"key"` + } `json:"app"` } // DeleteAppInstanceInput represents input for deleting an app instance type DeleteAppInstanceInput struct { - Key AppInstanceKey `json:"key"` + Region string `json:"region"` + AppInst struct { + Key AppInstanceKey `json:"key"` + } `json:"appinst"` } // Response wrapper types