fix(sdk): correct delete payload structure for v2 API and add delete command

The v2 API requires a different JSON payload structure than what was being sent.
Both DeleteApp and DeleteAppInstance needed to wrap their parameters properly.

SDK Changes:
- Update DeleteAppInput to use {region, app: {key}} structure
- Update DeleteAppInstanceInput to use {region, appinst: {key}} structure
- Fix DeleteApp method to populate new payload structure
- Fix DeleteAppInstance method to populate new payload structure

CLI Changes:
- Add delete command with -f flag for config file specification
- Support --dry-run to preview deletions
- Support --auto-approve to skip confirmation
- Implement v1 and v2 API support following same pattern as apply
- Add deletion planner to discover resources matching config
- Add resource manager to execute deletions (instances first, then app)

Test Changes:
- Update example_test.go to use EdgeConnectConfig_v1.yaml
- All tests passing including comprehensive delete test coverage

Verified working with manual API testing against live endpoint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Richard Robert Reitz 2025-10-20 15:15:23 +02:00
parent f921169351
commit df697c0ff6
14 changed files with 1921 additions and 5 deletions

View file

@ -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...)
}
}

View file

@ -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
}

157
internal/delete/v1/types.go Normal file
View file

@ -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
}

View file

@ -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...)
}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

157
internal/delete/v2/types.go Normal file
View file

@ -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
}

View file

@ -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)")
}