diff --git a/edge-connect-client b/edge-connect-client new file mode 100755 index 0000000..ab1bbd2 Binary files /dev/null and b/edge-connect-client differ diff --git a/hexagonal-architecture-proposal.md b/hexagonal-architecture-proposal.md new file mode 100644 index 0000000..b190b2d --- /dev/null +++ b/hexagonal-architecture-proposal.md @@ -0,0 +1,76 @@ +# Proposal: Refactor to Hexagonal Architecture + +This document proposes a refactoring of the `edge-connect-client` project to a Hexagonal Architecture (also known as Ports and Adapters). This will improve the project's maintainability, testability, and flexibility. + +## Current Architecture + +The current project structure is a mix of concerns. The `cmd` package contains both CLI handling and business logic, the `sdk` package is a client for the EdgeXR API, and the `internal` package contains some business logic and configuration handling. This makes it difficult to test the business logic in isolation and to adapt the application to different use cases. + +## Proposed Hexagonal Architecture + +The hexagonal architecture separates the application's core business logic from the outside world. The core communicates with the outside world through ports (interfaces), which are implemented by adapters. + +Here is the proposed directory structure: + +``` +. +├── cmd/ +│ └── main.go +├── internal/ +│ ├── core/ +│ │ ├── domain/ +│ │ │ ├── app.go +│ │ │ └── instance.go +│ │ ├── ports/ +│ │ │ ├── driven/ +│ │ │ │ ├── app_repository.go +│ │ │ │ └── instance_repository.go +│ │ │ └── driving/ +│ │ │ ├── app_service.go +│ │ │ └── instance_service.go +│ │ └── services/ +│ │ ├── app_service.go +│ │ └── instance_service.go +│ └── adapters/ +│ ├── cli/ +│ │ ├── app.go +│ │ └── instance.go +│ └── edgeconnect/ +│ ├── app.go +│ └── instance.go +├── go.mod +└── go.sum +``` + +### Core + +* `internal/core/domain`: Contains the core domain objects (e.g., `App`, `AppInstance`). These are plain Go structs with no external dependencies. +* `internal/core/ports`: Defines the interfaces for communication with the outside world. + * `driving`: Interfaces for the services offered by the application (e.g., `AppService`, `InstanceService`). + * `driven`: Interfaces for the services the application needs (e.g., `AppRepository`, `InstanceRepository`). +* `internal/core/services`: Implements the `driving` port interfaces. This is where the core business logic resides. + +### Adapters + +* `internal/adapters/cli`: The CLI adapter. It implements the user interface and calls the `driving` ports of the core. +* `internal/adapters/edgeconnect`: The EdgeXR API adapter. It implements the `driven` port interfaces and communicates with the EdgeXR API. + +### `cmd` + +* `cmd/main.go`: The main entry point of the application. It is responsible for wiring everything together: creating the adapters, injecting them into the core services, and starting the CLI. + +## Refactoring Steps + +1. **Define domain models:** Create the domain models in `internal/core/domain`. +2. **Define ports:** Define the `driving` and `driven` port interfaces in `internal/core/ports`. +3. **Implement core services:** Implement the core business logic in `internal/core/services`. +4. **Create adapters:** + * Move the existing CLI code from `cmd` to `internal/adapters/cli` and adapt it to call the core services. + * Move the existing `sdk` code to `internal/adapters/edgeconnect` and adapt it to implement the repository interfaces. +5. **Wire everything together:** Update `cmd/main.go` to create the adapters and inject them into the core services. + +## Benefits + +* **Improved Testability:** The core business logic can be tested in isolation, without the need for the CLI framework or the EdgeXR API. +* **Increased Flexibility:** The application can be easily adapted to different use cases by creating new adapters. For example, we could add a REST API by creating a new adapter. +* **Better Separation of Concerns:** The hexagonal architecture enforces a clear separation between the business logic and the infrastructure, making the code easier to understand and maintain. diff --git a/cmd/app.go b/internal/adapters/cli/app.go similarity index 54% rename from cmd/app.go rename to internal/adapters/cli/app.go index a9f187f..9033538 100644 --- a/cmd/app.go +++ b/internal/adapters/cli/app.go @@ -1,16 +1,12 @@ -package cmd +package cli import ( "context" "fmt" - "net/http" - "net/url" "os" - "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -20,58 +16,6 @@ var ( region string ) -func validateBaseURL(baseURL string) error { - url, err := url.Parse(baseURL) - if err != nil { - return fmt.Errorf("decoding error '%s'", err.Error()) - } - - if url.Scheme == "" { - return fmt.Errorf("schema should be set (add https://)") - } - - if len(url.User.Username()) > 0 { - return fmt.Errorf("user and or password should not be set") - } - - if !(url.Path == "" || url.Path == "/") { - return fmt.Errorf("should not contain any path '%s'", url.Path) - } - - if len(url.Query()) > 0 { - return fmt.Errorf("should not contain any queries '%s'", url.RawQuery) - } - - if len(url.Fragment) > 0 { - return fmt.Errorf("should not contain any fragment '%s'", url.Fragment) - } - - return nil -} - -func newSDKClient() *edgeconnect.Client { - baseURL := viper.GetString("base_url") - username := viper.GetString("username") - password := viper.GetString("password") - - err := validateBaseURL(baseURL) - if err != nil { - fmt.Printf("Error parsing baseURL: '%s' with error: %s\n", baseURL, err.Error()) - os.Exit(1) - } - - if username != "" && password != "" { - return edgeconnect.NewClientWithCredentials(baseURL, username, password, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - ) - } - - // Fallback to no auth for now - in production should require auth - return edgeconnect.NewClient(baseURL, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - ) -} - var appCmd = &cobra.Command{ Use: "app", Short: "Manage Edge Connect applications", @@ -82,19 +26,15 @@ var createAppCmd = &cobra.Command{ Use: "create", Short: "Create a new Edge Connect application", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - input := &edgeconnect.NewAppInput{ - Region: region, - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ - Organization: organization, - Name: appName, - Version: appVersion, - }, + app := &domain.App{ + Key: domain.AppKey{ + Organization: organization, + Name: appName, + Version: appVersion, }, } - err := c.CreateApp(context.Background(), input) + err := appService.CreateApp(context.Background(), region, app) if err != nil { fmt.Printf("Error creating app: %v\n", err) os.Exit(1) @@ -107,14 +47,13 @@ var showAppCmd = &cobra.Command{ Use: "show", Short: "Show details of an Edge Connect application", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - appKey := edgeconnect.AppKey{ + appKey := domain.AppKey{ Organization: organization, Name: appName, Version: appVersion, } - app, err := c.ShowApp(context.Background(), appKey, region) + app, err := appService.ShowApp(context.Background(), region, appKey) if err != nil { fmt.Printf("Error showing app: %v\n", err) os.Exit(1) @@ -127,14 +66,13 @@ var listAppsCmd = &cobra.Command{ Use: "list", Short: "List Edge Connect applications", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - appKey := edgeconnect.AppKey{ + appKey := domain.AppKey{ Organization: organization, Name: appName, Version: appVersion, } - apps, err := c.ShowApps(context.Background(), appKey, region) + apps, err := appService.ShowApps(context.Background(), region, appKey) if err != nil { fmt.Printf("Error listing apps: %v\n", err) os.Exit(1) @@ -150,14 +88,13 @@ var deleteAppCmd = &cobra.Command{ Use: "delete", Short: "Delete an Edge Connect application", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - appKey := edgeconnect.AppKey{ + appKey := domain.AppKey{ Organization: organization, Name: appName, Version: appVersion, } - err := c.DeleteApp(context.Background(), appKey, region) + err := appService.DeleteApp(context.Background(), region, appKey) if err != nil { fmt.Printf("Error deleting app: %v\n", err) os.Exit(1) @@ -185,4 +122,4 @@ func init() { for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} { cmd.MarkFlagRequired("name") } -} +} \ No newline at end of file diff --git a/cmd/app_test.go b/internal/adapters/cli/app_test.go similarity index 99% rename from cmd/app_test.go rename to internal/adapters/cli/app_test.go index 4b856ea..94df460 100644 --- a/cmd/app_test.go +++ b/internal/adapters/cli/app_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "testing" diff --git a/cmd/apply.go b/internal/adapters/cli/apply.go similarity index 77% rename from cmd/apply.go rename to internal/adapters/cli/apply.go index 41e94e9..5f18cea 100644 --- a/cmd/apply.go +++ b/internal/adapters/cli/apply.go @@ -1,16 +1,19 @@ // ABOUTME: CLI command for declarative deployment of EdgeConnect applications from YAML configuration // ABOUTME: Integrates config parser, deployment planner, and resource manager for complete deployment workflow -package cmd +package cli import ( "context" "fmt" "log" + "net/http" "os" "path/filepath" "strings" + "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/apply" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "github.com/spf13/cobra" ) @@ -68,10 +71,32 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name) // Step 3: Create EdgeConnect client - client := newSDKClient() + baseURL := getEnvOrDefault("EDGEXR_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live") + token := getEnvOrDefault("EDGEXR_TOKEN", "") + username := getEnvOrDefault("EDGEXR_USERNAME", "") + password := getEnvOrDefault("EDGEXR_PASSWORD", "") + + var client *edgeconnect.Client + + if token != "" { + fmt.Println("🔐 Using Bearer token authentication") + client = edgeconnect.NewClient(baseURL, + edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), + edgeconnect.WithLogger(log.Default()), + ) + } else if username != "" && password != "" { + fmt.Println("🔐 Using username/password authentication") + client = edgeconnect.NewClientWithCredentials(baseURL, username, password, + edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect.WithLogger(log.Default()), + ) + } else { + log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD") + } // Step 4: Create deployment planner - planner := apply.NewPlanner(client) + planner := apply.NewPlanner(client, client) // Step 5: Generate deployment plan fmt.Println("🔍 Analyzing current state and generating deployment plan...") @@ -121,7 +146,7 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { // Step 9: Execute deployment fmt.Println("\n🚀 Starting deployment...") - manager := apply.NewResourceManager(client, apply.WithLogger(log.Default())) + manager := apply.NewResourceManager(client, client, apply.WithLogger(log.Default())) deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent) if err != nil { return fmt.Errorf("deployment failed: %w", err) @@ -166,6 +191,13 @@ func confirmDeployment() bool { } } +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + func init() { rootCmd.AddCommand(applyCmd) diff --git a/cmd/instance.go b/internal/adapters/cli/instance.go similarity index 73% rename from cmd/instance.go rename to internal/adapters/cli/instance.go index de22062..7237bf9 100644 --- a/cmd/instance.go +++ b/internal/adapters/cli/instance.go @@ -1,11 +1,11 @@ -package cmd +package cli import ( "context" "fmt" "os" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "github.com/spf13/cobra" ) @@ -26,30 +26,26 @@ var createInstanceCmd = &cobra.Command{ Use: "create", Short: "Create a new Edge Connect application instance", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - input := &edgeconnect.NewAppInstanceInput{ - Region: region, - AppInst: edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ - Organization: organization, - Name: instanceName, - CloudletKey: edgeconnect.CloudletKey{ - Organization: cloudletOrg, - Name: cloudletName, - }, - }, - AppKey: edgeconnect.AppKey{ - Organization: organization, - Name: appName, - Version: appVersion, - }, - Flavor: edgeconnect.Flavor{ - Name: flavorName, + appInst := &domain.AppInstance{ + Key: domain.AppInstanceKey{ + Organization: organization, + Name: instanceName, + CloudletKey: domain.CloudletKey{ + Organization: cloudletOrg, + Name: cloudletName, }, }, + AppKey: domain.AppKey{ + Organization: organization, + Name: appName, + Version: appVersion, + }, + Flavor: domain.Flavor{ + Name: flavorName, + }, } - err := c.CreateAppInstance(context.Background(), input) + err := instanceService.CreateAppInstance(context.Background(), region, appInst) if err != nil { fmt.Printf("Error creating app instance: %v\n", err) os.Exit(1) @@ -62,17 +58,16 @@ var showInstanceCmd = &cobra.Command{ Use: "show", Short: "Show details of an Edge Connect application instance", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := domain.AppInstanceKey{ Organization: organization, Name: instanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: domain.CloudletKey{ Organization: cloudletOrg, Name: cloudletName, }, } - instance, err := c.ShowAppInstance(context.Background(), instanceKey, region) + instance, err := instanceService.ShowAppInstance(context.Background(), region, instanceKey) if err != nil { fmt.Printf("Error showing app instance: %v\n", err) os.Exit(1) @@ -85,17 +80,16 @@ var listInstancesCmd = &cobra.Command{ Use: "list", Short: "List Edge Connect application instances", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := domain.AppInstanceKey{ Organization: organization, Name: instanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: domain.CloudletKey{ Organization: cloudletOrg, Name: cloudletName, }, } - instances, err := c.ShowAppInstances(context.Background(), instanceKey, region) + instances, err := instanceService.ShowAppInstances(context.Background(), region, instanceKey) if err != nil { fmt.Printf("Error listing app instances: %v\n", err) os.Exit(1) @@ -111,17 +105,16 @@ var deleteInstanceCmd = &cobra.Command{ Use: "delete", Short: "Delete an Edge Connect application instance", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := domain.AppInstanceKey{ Organization: organization, Name: instanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: domain.CloudletKey{ Organization: cloudletOrg, Name: cloudletName, }, } - err := c.DeleteAppInstance(context.Background(), instanceKey, region) + err := instanceService.DeleteAppInstance(context.Background(), region, instanceKey) if err != nil { fmt.Printf("Error deleting app instance: %v\n", err) os.Exit(1) @@ -156,4 +149,4 @@ func init() { createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)") createInstanceCmd.MarkFlagRequired("app") createInstanceCmd.MarkFlagRequired("flavor") -} +} \ No newline at end of file diff --git a/cmd/root.go b/internal/adapters/cli/root.go similarity index 76% rename from cmd/root.go rename to internal/adapters/cli/root.go index 480d8f5..09f3728 100644 --- a/cmd/root.go +++ b/internal/adapters/cli/root.go @@ -1,9 +1,10 @@ -package cmd +package cli import ( "fmt" "os" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driving" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -13,8 +14,27 @@ var ( baseURL string username string password string + + appService driving.AppService + instanceService driving.AppInstanceService + cloudletService driving.CloudletService ) +// SetAppService injects the application service +func SetAppService(service driving.AppService) { + appService = service +} + +// SetInstanceService injects the instance service +func SetInstanceService(service driving.AppInstanceService) { + instanceService = service +} + +// SetCloudletService injects the cloudlet service +func SetCloudletService(service driving.CloudletService) { + cloudletService = service +} + // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "edge-connect", @@ -69,4 +89,4 @@ func initConfig() { if err := viper.ReadInConfig(); err == nil { fmt.Println("Using config file:", viper.ConfigFileUsed()) } -} +} \ No newline at end of file diff --git a/sdk/edgeconnect/appinstance.go b/internal/adapters/edgeconnect/appinstance.go similarity index 61% rename from sdk/edgeconnect/appinstance.go rename to internal/adapters/edgeconnect/appinstance.go index 8d568a8..c89bea5 100644 --- a/sdk/edgeconnect/appinstance.go +++ b/internal/adapters/edgeconnect/appinstance.go @@ -9,13 +9,19 @@ import ( "fmt" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/internal/http" ) // CreateAppInstance creates a new application instance in the specified region // Maps to POST /auth/ctrl/CreateAppInst -func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInput) error { +func (c *Client) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { transport := c.getTransport() + apiAppInst := ToAPIAppInstance(appInst) + input := &NewAppInstanceInput{ + Region: region, + AppInst: *apiAppInst, + } url := c.BaseURL + "/api/v1/auth/ctrl/CreateAppInst" resp, err := transport.Call(ctx, "POST", url, input) @@ -36,52 +42,55 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp // ShowAppInstance retrieves a single application instance by key and region // Maps to POST /auth/ctrl/ShowAppInst -func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) (AppInstance, error) { +func (c *Client) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" + apiAppInstKey := toAPIAppInstanceKey(appInstKey) filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: appInstKey}, + AppInstance: AppInstance{Key: *apiAppInstKey}, Region: region, } resp, err := transport.Call(ctx, "POST", url, filter) if err != nil { - return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err) + return nil, fmt.Errorf("ShowAppInstance failed: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return AppInstance{}, fmt.Errorf("app instance %s/%s: %w", + return nil, fmt.Errorf("app instance %s/%s: %w", appInstKey.Organization, appInstKey.Name, ErrResourceNotFound) } if resp.StatusCode >= 400 { - return AppInstance{}, c.handleErrorResponse(resp, "ShowAppInstance") + return nil, c.handleErrorResponse(resp, "ShowAppInstance") } // Parse streaming JSON response var appInstances []AppInstance if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { - return AppInstance{}, fmt.Errorf("ShowAppInstance failed to parse response: %w", err) + return nil, fmt.Errorf("ShowAppInstance failed to parse response: %w", err) } if len(appInstances) == 0 { - return AppInstance{}, fmt.Errorf("app instance %s/%s in region %s: %w", + return nil, fmt.Errorf("app instance %s/%s in region %s: %w", appInstKey.Organization, appInstKey.Name, region, ErrResourceNotFound) } - return appInstances[0], nil + domainAppInst := toDomainAppInstance(&appInstances[0]) + return &domainAppInst, nil } // ShowAppInstances retrieves all application instances matching the filter criteria // Maps to POST /auth/ctrl/ShowAppInst -func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, region string) ([]AppInstance, error) { +func (c *Client) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" + apiAppInstKey := toAPIAppInstanceKey(appInstKey) filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: appInstKey}, + AppInstance: AppInstance{Key: *apiAppInstKey}, Region: region, } @@ -97,7 +106,7 @@ func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey var appInstances []AppInstance if resp.StatusCode == http.StatusNotFound { - return appInstances, nil // Return empty slice for not found + return []domain.AppInstance{}, nil // Return empty slice for not found } if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { @@ -105,15 +114,26 @@ func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey } c.logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances)) - return appInstances, nil + + domainAppInsts := make([]domain.AppInstance, len(appInstances)) + for i := range appInstances { + domainAppInsts[i] = toDomainAppInstance(&appInstances[i]) + } + + return domainAppInsts, nil } // UpdateAppInstance updates an application instance and then refreshes it // Maps to POST /auth/ctrl/UpdateAppInst -func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstanceInput) error { +func (c *Client) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/UpdateAppInst" + apiAppInst := ToAPIAppInstance(appInst) + input := &UpdateAppInstanceInput{ + Region: region, + AppInst: *apiAppInst, + } resp, err := transport.Call(ctx, "POST", url, input) if err != nil { return fmt.Errorf("UpdateAppInstance failed: %w", err) @@ -132,12 +152,13 @@ func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstance // RefreshAppInstance refreshes an application instance's state // Maps to POST /auth/ctrl/RefreshAppInst -func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error { +func (c *Client) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/RefreshAppInst" + apiAppInstKey := toAPIAppInstanceKey(appInstKey) filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: appInstKey}, + AppInstance: AppInstance{Key: *apiAppInstKey}, Region: region, } @@ -159,12 +180,13 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK // DeleteAppInstance removes an application instance from the specified region // Maps to POST /auth/ctrl/DeleteAppInst -func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error { +func (c *Client) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst" + apiAppInstKey := toAPIAppInstanceKey(appInstKey) filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: appInstKey}, + AppInstance: AppInstance{Key: *apiAppInstKey}, Region: region, } @@ -233,3 +255,55 @@ func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result i return nil } + +func ToAPIAppInstance(appInst *domain.AppInstance) *AppInstance { + return &AppInstance{ + Key: *toAPIAppInstanceKey(appInst.Key), + AppKey: *toAPIAppKey(appInst.AppKey), + Flavor: toAPIFlavor(appInst.Flavor), + State: appInst.State, + PowerState: appInst.PowerState, + Fields: appInst.Fields, + } +} + +func toDomainAppInstance(appInst *AppInstance) domain.AppInstance { + return domain.AppInstance{ + Key: toDomainAppInstanceKey(appInst.Key), + AppKey: toDomainAppKey(appInst.AppKey), + Flavor: toDomainFlavor(appInst.Flavor), + State: appInst.State, + PowerState: appInst.PowerState, + Fields: appInst.Fields, + } +} + +func toAPIAppInstanceKey(key domain.AppInstanceKey) *AppInstanceKey { + return &AppInstanceKey{ + Organization: key.Organization, + Name: key.Name, + CloudletKey: toAPICloudletKey(key.CloudletKey), + } +} + +func toDomainAppInstanceKey(key AppInstanceKey) domain.AppInstanceKey { + return domain.AppInstanceKey{ + Organization: key.Organization, + Name: key.Name, + CloudletKey: toDomainCloudletKey(key.CloudletKey), + } +} + +func toAPICloudletKey(key domain.CloudletKey) CloudletKey { + return CloudletKey{ + Organization: key.Organization, + Name: key.Name, + } +} + +func toDomainCloudletKey(key CloudletKey) domain.CloudletKey { + return domain.CloudletKey{ + Organization: key.Organization, + Name: key.Name, + } +} \ No newline at end of file diff --git a/sdk/edgeconnect/appinstance_test.go b/internal/adapters/edgeconnect/appinstance_test.go similarity index 80% rename from sdk/edgeconnect/appinstance_test.go rename to internal/adapters/edgeconnect/appinstance_test.go index fc8bfc4..27bfeae 100644 --- a/sdk/edgeconnect/appinstance_test.go +++ b/internal/adapters/edgeconnect/appinstance_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -86,7 +87,23 @@ func TestCreateAppInstance(t *testing.T) { // Execute test ctx := context.Background() - err := client.CreateAppInstance(ctx, tt.input) + domainAppInst := &domain.AppInstance{ + Key: domain.AppInstanceKey{ + Organization: tt.input.AppInst.Key.Organization, + Name: tt.input.AppInst.Key.Name, + CloudletKey: domain.CloudletKey{ + Organization: tt.input.AppInst.Key.CloudletKey.Organization, + Name: tt.input.AppInst.Key.CloudletKey.Name, + }, + }, + AppKey: domain.AppKey{ + Organization: tt.input.AppInst.AppKey.Organization, + Name: tt.input.AppInst.AppKey.Name, + Version: tt.input.AppInst.AppKey.Version, + }, + Flavor: domain.Flavor{Name: tt.input.AppInst.Flavor.Name}, + } + err := client.CreateAppInstance(ctx, tt.input.Region, domainAppInst) // Verify results if tt.expectError { @@ -164,7 +181,15 @@ func TestShowAppInstance(t *testing.T) { // Execute test ctx := context.Background() - appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region) + domainAppInstKey := domain.AppInstanceKey{ + Organization: tt.appInstKey.Organization, + Name: tt.appInstKey.Name, + CloudletKey: domain.CloudletKey{ + Organization: tt.appInstKey.CloudletKey.Organization, + Name: tt.appInstKey.CloudletKey.Name, + }, + } + appInst, err := client.ShowAppInstance(ctx, tt.region, domainAppInstKey) // Verify results if tt.expectError { @@ -206,7 +231,8 @@ func TestShowAppInstances(t *testing.T) { client := NewClient(server.URL) ctx := context.Background() - appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, "us-west") + domainAppInstKey := domain.AppInstanceKey{Organization: "testorg"} + appInstances, err := client.ShowAppInstances(ctx, "us-west", domainAppInstKey) require.NoError(t, err) assert.Len(t, appInstances, 2) @@ -318,7 +344,24 @@ func TestUpdateAppInstance(t *testing.T) { // Execute test ctx := context.Background() - err := client.UpdateAppInstance(ctx, tt.input) + domainAppInst := &domain.AppInstance{ + Key: domain.AppInstanceKey{ + Organization: tt.input.AppInst.Key.Organization, + Name: tt.input.AppInst.Key.Name, + CloudletKey: domain.CloudletKey{ + Organization: tt.input.AppInst.Key.CloudletKey.Organization, + Name: tt.input.AppInst.Key.CloudletKey.Name, + }, + }, + AppKey: domain.AppKey{ + Organization: tt.input.AppInst.AppKey.Organization, + Name: tt.input.AppInst.AppKey.Name, + Version: tt.input.AppInst.AppKey.Version, + }, + Flavor: domain.Flavor{Name: tt.input.AppInst.Flavor.Name}, + PowerState: tt.input.AppInst.PowerState, + } + err := client.UpdateAppInstance(ctx, tt.input.Region, domainAppInst) // Verify results if tt.expectError { @@ -381,7 +424,15 @@ func TestRefreshAppInstance(t *testing.T) { client := NewClient(server.URL) ctx := context.Background() - err := client.RefreshAppInstance(ctx, tt.appInstKey, tt.region) + domainAppInstKey := domain.AppInstanceKey{ + Organization: tt.appInstKey.Organization, + Name: tt.appInstKey.Name, + CloudletKey: domain.CloudletKey{ + Organization: tt.appInstKey.CloudletKey.Organization, + Name: tt.appInstKey.CloudletKey.Name, + }, + } + err := client.RefreshAppInstance(ctx, tt.region, domainAppInstKey) if tt.expectError { assert.Error(t, err) @@ -457,7 +508,15 @@ func TestDeleteAppInstance(t *testing.T) { client := NewClient(server.URL) ctx := context.Background() - err := client.DeleteAppInstance(ctx, tt.appInstKey, tt.region) + domainAppInstKey := domain.AppInstanceKey{ + Organization: tt.appInstKey.Organization, + Name: tt.appInstKey.Name, + CloudletKey: domain.CloudletKey{ + Organization: tt.appInstKey.CloudletKey.Organization, + Name: tt.appInstKey.CloudletKey.Name, + }, + } + err := client.DeleteAppInstance(ctx, tt.region, domainAppInstKey) if tt.expectError { assert.Error(t, err) diff --git a/sdk/edgeconnect/apps.go b/internal/adapters/edgeconnect/apps.go similarity index 58% rename from sdk/edgeconnect/apps.go rename to internal/adapters/edgeconnect/apps.go index 70f5dea..4b49df7 100644 --- a/sdk/edgeconnect/apps.go +++ b/internal/adapters/edgeconnect/apps.go @@ -10,7 +10,8 @@ import ( "io" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/internal/http" ) var ( @@ -20,10 +21,16 @@ var ( // CreateApp creates a new application in the specified region // Maps to POST /auth/ctrl/CreateApp -func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error { +func (c *Client) CreateApp(ctx context.Context, region string, app *domain.App) error { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/CreateApp" + apiApp := toAPIApp(app) + input := &NewAppInput{ + Region: region, + App: *apiApp, + } + resp, err := transport.Call(ctx, "POST", url, input) if err != nil { return fmt.Errorf("CreateApp failed: %w", err) @@ -42,52 +49,55 @@ func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error { // ShowApp retrieves a single application by key and region // Maps to POST /auth/ctrl/ShowApp -func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App, error) { +func (c *Client) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp" + apiAppKey := toAPIAppKey(appKey) filter := AppFilter{ - App: App{Key: appKey}, + App: App{Key: *apiAppKey}, Region: region, } resp, err := transport.Call(ctx, "POST", url, filter) if err != nil { - return App{}, fmt.Errorf("ShowApp failed: %w", err) + return nil, fmt.Errorf("ShowApp failed: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", + return nil, fmt.Errorf("app %s/%s version %s in region %s: %w", appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound) } if resp.StatusCode >= 400 { - return App{}, c.handleErrorResponse(resp, "ShowApp") + return nil, c.handleErrorResponse(resp, "ShowApp") } // Parse streaming JSON response var apps []App if err := c.parseStreamingResponse(resp, &apps); err != nil { - return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err) + return nil, fmt.Errorf("ShowApp failed to parse response: %w", err) } if len(apps) == 0 { - return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", + return nil, fmt.Errorf("app %s/%s version %s in region %s: %w", appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound) } - return apps[0], nil + domainApp := toDomainApp(&apps[0]) + return &domainApp, nil } // ShowApps retrieves all applications matching the filter criteria // Maps to POST /auth/ctrl/ShowApp -func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]App, error) { +func (c *Client) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp" + apiAppKey := toAPIAppKey(appKey) filter := AppFilter{ - App: App{Key: appKey}, + App: App{Key: *apiAppKey}, Region: region, } @@ -103,7 +113,7 @@ func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([] var apps []App if resp.StatusCode == http.StatusNotFound { - return apps, nil // Return empty slice for not found + return []domain.App{}, nil // Return empty slice for not found } if err := c.parseStreamingResponse(resp, &apps); err != nil { @@ -111,15 +121,27 @@ func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([] } c.logf("ShowApps: found %d apps matching criteria", len(apps)) - return apps, nil + + domainApps := make([]domain.App, len(apps)) + for i := range apps { + domainApps[i] = toDomainApp(&apps[i]) + } + + return domainApps, nil } // UpdateApp updates the definition of an application // Maps to POST /auth/ctrl/UpdateApp -func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error { +func (c *Client) UpdateApp(ctx context.Context, region string, app *domain.App) error { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/UpdateApp" + apiApp := toAPIApp(app) + input := &UpdateAppInput{ + Region: region, + App: *apiApp, + } + resp, err := transport.Call(ctx, "POST", url, input) if err != nil { return fmt.Errorf("UpdateApp failed: %w", err) @@ -138,12 +160,13 @@ func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error { // DeleteApp removes an application from the specified region // Maps to POST /auth/ctrl/DeleteApp -func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) error { +func (c *Client) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp" + apiAppKey := toAPIAppKey(appKey) filter := AppFilter{ - App: App{Key: appKey}, + App: App{Key: *apiAppKey}, Region: region, } @@ -249,3 +272,85 @@ func (c *Client) handleErrorResponse(resp *http.Response, operation string) erro Body: bodyBytes, } } + +func toAPIApp(app *domain.App) *App { + return &App{ + Key: *toAPIAppKey(app.Key), + Deployment: app.Deployment, + ImageType: app.ImageType, + ImagePath: app.ImagePath, + AllowServerless: app.AllowServerless, + DefaultFlavor: toAPIFlavor(app.DefaultFlavor), + ServerlessConfig: app.ServerlessConfig, + DeploymentGenerator: app.DeploymentGenerator, + DeploymentManifest: app.DeploymentManifest, + RequiredOutboundConnections: toAPISecurityRules(app.RequiredOutboundConnections), + Fields: app.Fields, + } +} + +func toDomainApp(app *App) domain.App { + return domain.App{ + Key: toDomainAppKey(app.Key), + Deployment: app.Deployment, + ImageType: app.ImageType, + ImagePath: app.ImagePath, + AllowServerless: app.AllowServerless, + DefaultFlavor: toDomainFlavor(app.DefaultFlavor), + ServerlessConfig: app.ServerlessConfig, + DeploymentGenerator: app.DeploymentGenerator, + DeploymentManifest: app.DeploymentManifest, + RequiredOutboundConnections: ToDomainSecurityRules(app.RequiredOutboundConnections), + Fields: app.Fields, + } +} + +func toAPIAppKey(appKey domain.AppKey) *AppKey { + return &AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } +} + +func toDomainAppKey(appKey AppKey) domain.AppKey { + return domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } +} + +func toAPIFlavor(flavor domain.Flavor) Flavor { + return Flavor{Name: flavor.Name} +} + +func toDomainFlavor(flavor Flavor) domain.Flavor { + return domain.Flavor{Name: flavor.Name} +} + +func toAPISecurityRules(rules []domain.SecurityRule) []SecurityRule { + apiRules := make([]SecurityRule, len(rules)) + for i, r := range rules { + apiRules[i] = SecurityRule{ + PortRangeMax: r.PortRangeMax, + PortRangeMin: r.PortRangeMin, + Protocol: r.Protocol, + RemoteCIDR: r.RemoteCIDR, + } + } + return apiRules +} + +func ToDomainSecurityRules(rules []SecurityRule) []domain.SecurityRule { + domainRules := make([]domain.SecurityRule, len(rules)) + for i, r := range rules { + domainRules[i] = domain.SecurityRule{ + PortRangeMax: r.PortRangeMax, + PortRangeMin: r.PortRangeMin, + Protocol: r.Protocol, + RemoteCIDR: r.RemoteCIDR, + } + } + return domainRules +} \ No newline at end of file diff --git a/sdk/edgeconnect/apps_test.go b/internal/adapters/edgeconnect/apps_test.go similarity index 84% rename from sdk/edgeconnect/apps_test.go rename to internal/adapters/edgeconnect/apps_test.go index 30531f6..2441c7c 100644 --- a/sdk/edgeconnect/apps_test.go +++ b/internal/adapters/edgeconnect/apps_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -79,7 +80,20 @@ func TestCreateApp(t *testing.T) { // Execute test ctx := context.Background() - err := client.CreateApp(ctx, tt.input) + domainApp := &domain.App{ + Key: domain.AppKey{ + Organization: tt.input.App.Key.Organization, + Name: tt.input.App.Key.Name, + Version: tt.input.App.Key.Version, + }, + Deployment: tt.input.App.Deployment, + ImageType: tt.input.App.ImageType, + ImagePath: tt.input.App.ImagePath, + DefaultFlavor: domain.Flavor{Name: tt.input.App.DefaultFlavor.Name}, + ServerlessConfig: tt.input.App.ServerlessConfig, + AllowServerless: tt.input.App.AllowServerless, + } + err := client.CreateApp(ctx, tt.input.Region, domainApp) // Verify results if tt.expectError { @@ -151,7 +165,12 @@ func TestShowApp(t *testing.T) { // Execute test ctx := context.Background() - app, err := client.ShowApp(ctx, tt.appKey, tt.region) + domainAppKey := domain.AppKey{ + Organization: tt.appKey.Organization, + Name: tt.appKey.Name, + Version: tt.appKey.Version, + } + app, err := client.ShowApp(ctx, tt.region, domainAppKey) // Verify results if tt.expectError { @@ -193,7 +212,8 @@ func TestShowApps(t *testing.T) { client := NewClient(server.URL) ctx := context.Background() - apps, err := client.ShowApps(ctx, AppKey{Organization: "testorg"}, "us-west") + domainAppKey := domain.AppKey{Organization: "testorg"} + apps, err := client.ShowApps(ctx, "us-west", domainAppKey) require.NoError(t, err) assert.Len(t, apps, 2) @@ -289,7 +309,20 @@ func TestUpdateApp(t *testing.T) { // Execute test ctx := context.Background() - err := client.UpdateApp(ctx, tt.input) + domainApp := &domain.App{ + Key: domain.AppKey{ + Organization: tt.input.App.Key.Organization, + Name: tt.input.App.Key.Name, + Version: tt.input.App.Key.Version, + }, + Deployment: tt.input.App.Deployment, + ImageType: tt.input.App.ImageType, + ImagePath: tt.input.App.ImagePath, + DefaultFlavor: domain.Flavor{Name: tt.input.App.DefaultFlavor.Name}, + ServerlessConfig: tt.input.App.ServerlessConfig, + AllowServerless: tt.input.App.AllowServerless, + } + err := client.UpdateApp(ctx, tt.input.Region, domainApp) // Verify results if tt.expectError { @@ -357,7 +390,12 @@ func TestDeleteApp(t *testing.T) { client := NewClient(server.URL) ctx := context.Background() - err := client.DeleteApp(ctx, tt.appKey, tt.region) + domainAppKey := domain.AppKey{ + Organization: tt.appKey.Organization, + Name: tt.appKey.Name, + Version: tt.appKey.Version, + } + err := client.DeleteApp(ctx, tt.region, domainAppKey) if tt.expectError { assert.Error(t, err) diff --git a/sdk/edgeconnect/auth.go b/internal/adapters/edgeconnect/auth.go similarity index 100% rename from sdk/edgeconnect/auth.go rename to internal/adapters/edgeconnect/auth.go diff --git a/sdk/edgeconnect/auth_test.go b/internal/adapters/edgeconnect/auth_test.go similarity index 100% rename from sdk/edgeconnect/auth_test.go rename to internal/adapters/edgeconnect/auth_test.go diff --git a/sdk/edgeconnect/client.go b/internal/adapters/edgeconnect/client.go similarity index 100% rename from sdk/edgeconnect/client.go rename to internal/adapters/edgeconnect/client.go diff --git a/sdk/edgeconnect/cloudlet.go b/internal/adapters/edgeconnect/cloudlet.go similarity index 68% rename from sdk/edgeconnect/cloudlet.go rename to internal/adapters/edgeconnect/cloudlet.go index e3f4b7d..e208754 100644 --- a/sdk/edgeconnect/cloudlet.go +++ b/internal/adapters/edgeconnect/cloudlet.go @@ -9,15 +9,22 @@ import ( "fmt" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/internal/http" ) // CreateCloudlet creates a new cloudlet in the specified region // Maps to POST /auth/ctrl/CreateCloudlet -func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) error { +func (c *Client) CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/CreateCloudlet" + apiCloudlet := toAPICloudlet(cloudlet) + input := &NewCloudletInput{ + Region: region, + Cloudlet: *apiCloudlet, + } + resp, err := transport.Call(ctx, "POST", url, input) if err != nil { return fmt.Errorf("CreateCloudlet failed: %w", err) @@ -36,52 +43,55 @@ func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) er // ShowCloudlet retrieves a single cloudlet by key and region // Maps to POST /auth/ctrl/ShowCloudlet -func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) (Cloudlet, error) { +func (c *Client) ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet" + apiCloudletKey := toAPICloudletKey(cloudletKey) filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: cloudletKey}, + Cloudlet: Cloudlet{Key: apiCloudletKey}, Region: region, } resp, err := transport.Call(ctx, "POST", url, filter) if err != nil { - return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err) + return nil, fmt.Errorf("ShowCloudlet failed: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", + return nil, fmt.Errorf("cloudlet %s/%s in region %s: %w", cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) } if resp.StatusCode >= 400 { - return Cloudlet{}, c.handleErrorResponse(resp, "ShowCloudlet") + return nil, c.handleErrorResponse(resp, "ShowCloudlet") } // Parse streaming JSON response var cloudlets []Cloudlet if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil { - return Cloudlet{}, fmt.Errorf("ShowCloudlet failed to parse response: %w", err) + return nil, fmt.Errorf("ShowCloudlet failed to parse response: %w", err) } if len(cloudlets) == 0 { - return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", + return nil, fmt.Errorf("cloudlet %s/%s in region %s: %w", cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) } - return cloudlets[0], nil + domainCloudlet := toDomainCloudlet(&cloudlets[0]) + return &domainCloudlet, nil } // ShowCloudlets retrieves all cloudlets matching the filter criteria // Maps to POST /auth/ctrl/ShowCloudlet -func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, region string) ([]Cloudlet, error) { +func (c *Client) ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet" + apiCloudletKey := toAPICloudletKey(cloudletKey) filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: cloudletKey}, + Cloudlet: Cloudlet{Key: apiCloudletKey}, Region: region, } @@ -97,7 +107,7 @@ func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, reg var cloudlets []Cloudlet if resp.StatusCode == http.StatusNotFound { - return cloudlets, nil // Return empty slice for not found + return []domain.Cloudlet{}, nil // Return empty slice for not found } if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil { @@ -105,17 +115,24 @@ func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, reg } c.logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets)) - return cloudlets, nil + + domainCloudlets := make([]domain.Cloudlet, len(cloudlets)) + for i := range cloudlets { + domainCloudlets[i] = toDomainCloudlet(&cloudlets[i]) + } + + return domainCloudlets, nil } // DeleteCloudlet removes a cloudlet from the specified region // Maps to POST /auth/ctrl/DeleteCloudlet -func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) error { +func (c *Client) DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/DeleteCloudlet" + apiCloudletKey := toAPICloudletKey(cloudletKey) filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: cloudletKey}, + Cloudlet: Cloudlet{Key: apiCloudletKey}, Region: region, } @@ -138,12 +155,13 @@ func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, re // GetCloudletManifest retrieves the deployment manifest for a cloudlet // Maps to POST /auth/ctrl/GetCloudletManifest -func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletManifest, error) { +func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey domain.CloudletKey, region string) (*CloudletManifest, error) { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletManifest" + apiCloudletKey := toAPICloudletKey(cloudletKey) filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: cloudletKey}, + Cloudlet: Cloudlet{Key: apiCloudletKey}, Region: region, } @@ -176,12 +194,13 @@ func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKe // GetCloudletResourceUsage retrieves resource usage information for a cloudlet // Maps to POST /auth/ctrl/GetCloudletResourceUsage -func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletResourceUsage, error) { +func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey domain.CloudletKey, region string) (*CloudletResourceUsage, error) { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletResourceUsage" + apiCloudletKey := toAPICloudletKey(cloudletKey) filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: cloudletKey}, + Cloudlet: Cloudlet{Key: apiCloudletKey}, Region: region, } @@ -269,3 +288,45 @@ func (c *Client) parseDirectJSONResponse(resp *http.Response, result interface{} } return nil } + +func toAPICloudlet(cloudlet *domain.Cloudlet) *Cloudlet { + return &Cloudlet{ + Key: toAPICloudletKey(cloudlet.Key), + Location: toAPILocation(cloudlet.Location), + IpSupport: cloudlet.IpSupport, + NumDynamicIps: cloudlet.NumDynamicIps, + State: cloudlet.State, + Flavor: toAPIFlavor(cloudlet.Flavor), + PhysicalName: cloudlet.PhysicalName, + Region: cloudlet.Region, + NotifySrvAddr: cloudlet.NotifySrvAddr, + } +} + +func toDomainCloudlet(cloudlet *Cloudlet) domain.Cloudlet { + return domain.Cloudlet{ + Key: toDomainCloudletKey(cloudlet.Key), + Location: toDomainLocation(cloudlet.Location), + IpSupport: cloudlet.IpSupport, + NumDynamicIps: cloudlet.NumDynamicIps, + State: cloudlet.State, + Flavor: toDomainFlavor(cloudlet.Flavor), + PhysicalName: cloudlet.PhysicalName, + Region: cloudlet.Region, + NotifySrvAddr: cloudlet.NotifySrvAddr, + } +} + +func toAPILocation(location domain.Location) Location { + return Location{ + Latitude: location.Latitude, + Longitude: location.Longitude, + } +} + +func toDomainLocation(location Location) domain.Location { + return domain.Location{ + Latitude: location.Latitude, + Longitude: location.Longitude, + } +} \ No newline at end of file diff --git a/sdk/edgeconnect/cloudlet_test.go b/internal/adapters/edgeconnect/cloudlet_test.go similarity index 86% rename from sdk/edgeconnect/cloudlet_test.go rename to internal/adapters/edgeconnect/cloudlet_test.go index 7d129bb..057baa8 100644 --- a/sdk/edgeconnect/cloudlet_test.go +++ b/internal/adapters/edgeconnect/cloudlet_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -82,7 +83,19 @@ func TestCreateCloudlet(t *testing.T) { // Execute test ctx := context.Background() - err := client.CreateCloudlet(ctx, tt.input) + domainCloudlet := &domain.Cloudlet{ + Key: domain.CloudletKey{ + Organization: tt.input.Cloudlet.Key.Organization, + Name: tt.input.Cloudlet.Key.Name, + }, + Location: domain.Location{ + Latitude: tt.input.Cloudlet.Location.Latitude, + Longitude: tt.input.Cloudlet.Location.Longitude, + }, + IpSupport: tt.input.Cloudlet.IpSupport, + NumDynamicIps: tt.input.Cloudlet.NumDynamicIps, + } + err := client.CreateCloudlet(ctx, tt.input.Region, domainCloudlet) // Verify results if tt.expectError { @@ -152,7 +165,11 @@ func TestShowCloudlet(t *testing.T) { // Execute test ctx := context.Background() - cloudlet, err := client.ShowCloudlet(ctx, tt.cloudletKey, tt.region) + domainCloudletKey := domain.CloudletKey{ + Organization: tt.cloudletKey.Organization, + Name: tt.cloudletKey.Name, + } + cloudlet, err := client.ShowCloudlet(ctx, tt.region, domainCloudletKey) // Verify results if tt.expectError { @@ -194,7 +211,8 @@ func TestShowCloudlets(t *testing.T) { client := NewClient(server.URL) ctx := context.Background() - cloudlets, err := client.ShowCloudlets(ctx, CloudletKey{Organization: "cloudletorg"}, "us-west") + domainCloudletKey := domain.CloudletKey{Organization: "cloudletorg"} + cloudlets, err := client.ShowCloudlets(ctx, "us-west", domainCloudletKey) require.NoError(t, err) assert.Len(t, cloudlets, 2) @@ -257,7 +275,11 @@ func TestDeleteCloudlet(t *testing.T) { client := NewClient(server.URL) ctx := context.Background() - err := client.DeleteCloudlet(ctx, tt.cloudletKey, tt.region) + domainCloudletKey := domain.CloudletKey{ + Organization: tt.cloudletKey.Organization, + Name: tt.cloudletKey.Name, + } + err := client.DeleteCloudlet(ctx, tt.region, domainCloudletKey) if tt.expectError { assert.Error(t, err) @@ -320,7 +342,11 @@ func TestGetCloudletManifest(t *testing.T) { client := NewClient(server.URL) ctx := context.Background() - manifest, err := client.GetCloudletManifest(ctx, tt.cloudletKey, tt.region) + domainCloudletKey := domain.CloudletKey{ + Organization: tt.cloudletKey.Organization, + Name: tt.cloudletKey.Name, + } + manifest, err := client.GetCloudletManifest(ctx, domainCloudletKey, tt.region) if tt.expectError { assert.Error(t, err) @@ -388,7 +414,11 @@ func TestGetCloudletResourceUsage(t *testing.T) { client := NewClient(server.URL) ctx := context.Background() - usage, err := client.GetCloudletResourceUsage(ctx, tt.cloudletKey, tt.region) + domainCloudletKey := domain.CloudletKey{ + Organization: tt.cloudletKey.Organization, + Name: tt.cloudletKey.Name, + } + usage, err := client.GetCloudletResourceUsage(ctx, domainCloudletKey, tt.region) if tt.expectError { assert.Error(t, err) diff --git a/sdk/edgeconnect/types.go b/internal/adapters/edgeconnect/types.go similarity index 99% rename from sdk/edgeconnect/types.go rename to internal/adapters/edgeconnect/types.go index 6f82d51..5fd5245 100644 --- a/sdk/edgeconnect/types.go +++ b/internal/adapters/edgeconnect/types.go @@ -7,6 +7,8 @@ import ( "encoding/json" "fmt" "time" + + ) // App field constants for partial updates (based on EdgeXR API specification) @@ -358,4 +360,4 @@ type CloudletResourceUsage struct { CloudletKey CloudletKey `json:"cloudlet_key"` Region string `json:"region"` Usage map[string]interface{} `json:"usage"` -} +} \ No newline at end of file diff --git a/sdk/internal/http/transport.go b/internal/adapters/internal/http/transport.go similarity index 100% rename from sdk/internal/http/transport.go rename to internal/adapters/internal/http/transport.go diff --git a/internal/apply/planner_test.go b/internal/apply/planner_test.go deleted file mode 100644 index d946a14..0000000 --- a/internal/apply/planner_test.go +++ /dev/null @@ -1,663 +0,0 @@ -// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios -// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios -package apply - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -// MockEdgeConnectClient is a mock implementation of the EdgeConnect client -type MockEdgeConnectClient struct { - mock.Mock -} - -func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) { - args := m.Called(ctx, appKey, region) - if args.Get(0) == nil { - return edgeconnect.App{}, args.Error(1) - } - return args.Get(0).(edgeconnect.App), args.Error(1) -} - -func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) { - args := m.Called(ctx, instanceKey, region) - if args.Get(0) == nil { - return edgeconnect.AppInstance{}, args.Error(1) - } - return args.Get(0).(edgeconnect.AppInstance), args.Error(1) -} - -func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { - args := m.Called(ctx, input) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error { - args := m.Called(ctx, input) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error { - args := m.Called(ctx, appKey, region) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error { - args := m.Called(ctx, input) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error { - args := m.Called(ctx, input) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error { - args := m.Called(ctx, instanceKey, region) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect.AppKey, region string) ([]edgeconnect.App, error) { - args := m.Called(ctx, appKey, region) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]edgeconnect.App), args.Error(1) -} - -func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) { - args := m.Called(ctx, instanceKey, region) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]edgeconnect.AppInstance), args.Error(1) -} - -func TestNewPlanner(t *testing.T) { - mockClient := &MockEdgeConnectClient{} - planner := NewPlanner(mockClient) - - assert.NotNil(t, planner) - assert.IsType(t, &EdgeConnectPlanner{}, planner) -} - -func TestDefaultPlanOptions(t *testing.T) { - opts := DefaultPlanOptions() - - assert.False(t, opts.DryRun) - assert.False(t, opts.Force) - assert.False(t, opts.SkipStateCheck) - assert.True(t, opts.ParallelQueries) - assert.Equal(t, 30*time.Second, opts.Timeout) -} - -func createTestConfig(t *testing.T) *config.EdgeConnectConfig { - // Create temporary manifest file - tempDir := t.TempDir() - manifestFile := filepath.Join(tempDir, "test-manifest.yaml") - manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" - err := os.WriteFile(manifestFile, []byte(manifestContent), 0644) - require.NoError(t, err) - - return &config.EdgeConnectConfig{ - Kind: "edgeconnect-deployment", - Metadata: config.Metadata{ - Name: "test-app", - AppVersion: "1.0.0", - Organization: "testorg", - }, - Spec: config.Spec{ - K8sApp: &config.K8sApp{ - ManifestFile: manifestFile, - }, - InfraTemplate: []config.InfraTemplate{ - { - Region: "US", - CloudletOrg: "TestCloudletOrg", - CloudletName: "TestCloudlet", - FlavorName: "small", - }, - }, - Network: &config.NetworkConfig{ - OutboundConnections: []config.OutboundConnection{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - }, - }, - }, - } -} - -func TestPlanNewDeployment(t *testing.T) { - mockClient := &MockEdgeConnectClient{} - planner := NewPlanner(mockClient) - testConfig := createTestConfig(t) - - // Mock API calls to return "not found" errors - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) - - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) - - ctx := context.Background() - result, err := planner.Plan(ctx, testConfig) - - require.NoError(t, err) - require.NotNil(t, result) - require.NotNil(t, result.Plan) - require.NoError(t, result.Error) - - plan := result.Plan - assert.Equal(t, "test-app", plan.ConfigName) - assert.Equal(t, ActionCreate, plan.AppAction.Type) - assert.Equal(t, "Application does not exist", plan.AppAction.Reason) - - require.Len(t, plan.InstanceActions, 1) - assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type) - assert.Equal(t, "Instance does not exist", plan.InstanceActions[0].Reason) - - assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance - assert.False(t, plan.IsEmpty()) - - mockClient.AssertExpectations(t) -} - -func TestPlanExistingDeploymentNoChanges(t *testing.T) { - mockClient := &MockEdgeConnectClient{} - planner := NewPlanner(mockClient) - testConfig := createTestConfig(t) - - // Note: We would calculate expected manifest hash here when API supports it - - // Mock existing app with same manifest hash and outbound connections - manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" - existingApp := &edgeconnect.App{ - Key: edgeconnect.AppKey{ - Organization: "testorg", - Name: "test-app", - Version: "1.0.0", - }, - Deployment: "kubernetes", - DeploymentManifest: manifestContent, - RequiredOutboundConnections: []edgeconnect.SecurityRule{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - }, - // Note: Manifest hash tracking would be implemented when API supports annotations - } - - // Mock existing instance - existingInstance := &edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ - Organization: "testorg", - Name: "test-app-1.0.0-instance", - CloudletKey: edgeconnect.CloudletKey{ - Organization: "TestCloudletOrg", - Name: "TestCloudlet", - }, - }, - AppKey: edgeconnect.AppKey{ - Organization: "testorg", - Name: "test-app", - Version: "1.0.0", - }, - Flavor: edgeconnect.Flavor{ - Name: "small", - }, - State: "Ready", - PowerState: "PowerOn", - } - - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(*existingApp, nil) - - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). - Return(*existingInstance, nil) - - ctx := context.Background() - result, err := planner.Plan(ctx, testConfig) - - require.NoError(t, err) - require.NotNil(t, result) - require.NotNil(t, result.Plan) - - plan := result.Plan - assert.Equal(t, ActionNone, plan.AppAction.Type) - assert.Len(t, plan.InstanceActions, 1) - assert.Equal(t, ActionNone, plan.InstanceActions[0].Type) - assert.Equal(t, 0, plan.TotalActions) - assert.True(t, plan.IsEmpty()) - assert.Contains(t, plan.Summary, "No changes required") - - mockClient.AssertExpectations(t) -} - -func TestPlanWithOptions(t *testing.T) { - mockClient := &MockEdgeConnectClient{} - planner := NewPlanner(mockClient) - testConfig := createTestConfig(t) - - opts := PlanOptions{ - DryRun: true, - SkipStateCheck: true, - Timeout: 10 * time.Second, - } - - ctx := context.Background() - result, err := planner.PlanWithOptions(ctx, testConfig, opts) - - require.NoError(t, err) - require.NotNil(t, result) - require.NotNil(t, result.Plan) - - plan := result.Plan - assert.True(t, plan.DryRun) - assert.Equal(t, ActionCreate, plan.AppAction.Type) - assert.Contains(t, plan.AppAction.Reason, "state check skipped") - - // No API calls should be made when SkipStateCheck is true - mockClient.AssertNotCalled(t, "ShowApp") - mockClient.AssertNotCalled(t, "ShowAppInstance") -} - -func TestPlanMultipleInfrastructures(t *testing.T) { - mockClient := &MockEdgeConnectClient{} - planner := NewPlanner(mockClient) - testConfig := createTestConfig(t) - - // Add a second infrastructure target - testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{ - Region: "EU", - CloudletOrg: "EUCloudletOrg", - CloudletName: "EUCloudlet", - FlavorName: "medium", - }) - - // Mock API calls to return "not found" errors - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) - - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) - - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "EU"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) - - ctx := context.Background() - result, err := planner.Plan(ctx, testConfig) - - require.NoError(t, err) - require.NotNil(t, result) - require.NotNil(t, result.Plan) - - plan := result.Plan - assert.Equal(t, ActionCreate, plan.AppAction.Type) - - // Should have 2 instance actions, one for each infrastructure - require.Len(t, plan.InstanceActions, 2) - assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type) - assert.Equal(t, ActionCreate, plan.InstanceActions[1].Type) - - assert.Equal(t, 3, plan.TotalActions) // 1 app + 2 instances - - // Test cloudlet and region aggregation - cloudlets := plan.GetTargetCloudlets() - regions := plan.GetTargetRegions() - assert.Len(t, cloudlets, 2) - assert.Len(t, regions, 2) - - mockClient.AssertExpectations(t) -} - -func TestCalculateManifestHash(t *testing.T) { - planner := &EdgeConnectPlanner{} - tempDir := t.TempDir() - - // Create test file - testFile := filepath.Join(tempDir, "test.yaml") - content := "test content for hashing" - err := os.WriteFile(testFile, []byte(content), 0644) - require.NoError(t, err) - - hash1, err := planner.calculateManifestHash(testFile) - require.NoError(t, err) - assert.NotEmpty(t, hash1) - assert.Len(t, hash1, 64) // SHA256 hex string length - - // Same content should produce same hash - hash2, err := planner.calculateManifestHash(testFile) - require.NoError(t, err) - assert.Equal(t, hash1, hash2) - - // Different content should produce different hash - err = os.WriteFile(testFile, []byte("different content"), 0644) - require.NoError(t, err) - - hash3, err := planner.calculateManifestHash(testFile) - require.NoError(t, err) - assert.NotEqual(t, hash1, hash3) - - // Empty file path should return empty hash - hash4, err := planner.calculateManifestHash("") - require.NoError(t, err) - assert.Empty(t, hash4) - - // Non-existent file should return error - _, err = planner.calculateManifestHash("/non/existent/file") - assert.Error(t, err) -} - -func TestCompareAppStates(t *testing.T) { - planner := &EdgeConnectPlanner{} - - current := &AppState{ - Name: "test-app", - Version: "1.0.0", - AppType: AppTypeK8s, - ManifestHash: "old-hash", - } - - desired := &AppState{ - Name: "test-app", - Version: "1.0.0", - AppType: AppTypeK8s, - ManifestHash: "new-hash", - } - - changes, manifestChanged := planner.compareAppStates(current, desired) - assert.Len(t, changes, 1) - assert.True(t, manifestChanged) - assert.Contains(t, changes[0], "Manifest hash changed") - - // Test no changes - desired.ManifestHash = "old-hash" - changes, manifestChanged = planner.compareAppStates(current, desired) - assert.Empty(t, changes) - assert.False(t, manifestChanged) - - // Test app type change - desired.AppType = AppTypeDocker - changes, manifestChanged = planner.compareAppStates(current, desired) - assert.Len(t, changes, 1) - assert.False(t, manifestChanged) - assert.Contains(t, changes[0], "App type changed") -} - -func TestCompareAppStatesOutboundConnections(t *testing.T) { - planner := &EdgeConnectPlanner{} - - // Test with no outbound connections - current := &AppState{ - Name: "test-app", - Version: "1.0.0", - AppType: AppTypeK8s, - OutboundConnections: nil, - } - - desired := &AppState{ - Name: "test-app", - Version: "1.0.0", - AppType: AppTypeK8s, - OutboundConnections: nil, - } - - changes, _ := planner.compareAppStates(current, desired) - assert.Empty(t, changes, "No changes expected when both have no outbound connections") - - // Test adding outbound connections - desired.OutboundConnections = []SecurityRule{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - } - - changes, _ = planner.compareAppStates(current, desired) - assert.Len(t, changes, 1) - assert.Contains(t, changes[0], "Outbound connections changed") - - // Test identical outbound connections - current.OutboundConnections = []SecurityRule{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - } - - changes, _ = planner.compareAppStates(current, desired) - assert.Empty(t, changes, "No changes expected when outbound connections are identical") - - // Test different outbound connections (different port) - desired.OutboundConnections[0].PortRangeMin = 443 - desired.OutboundConnections[0].PortRangeMax = 443 - - changes, _ = planner.compareAppStates(current, desired) - assert.Len(t, changes, 1) - assert.Contains(t, changes[0], "Outbound connections changed") - - // Test same connections but different order (should be considered equal) - current.OutboundConnections = []SecurityRule{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - { - Protocol: "tcp", - PortRangeMin: 443, - PortRangeMax: 443, - RemoteCIDR: "0.0.0.0/0", - }, - } - - desired.OutboundConnections = []SecurityRule{ - { - Protocol: "tcp", - PortRangeMin: 443, - PortRangeMax: 443, - RemoteCIDR: "0.0.0.0/0", - }, - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - } - - changes, _ = planner.compareAppStates(current, desired) - assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order") - - // Test removing outbound connections - desired.OutboundConnections = nil - - changes, _ = planner.compareAppStates(current, desired) - assert.Len(t, changes, 1) - assert.Contains(t, changes[0], "Outbound connections changed") -} - -func TestCompareInstanceStates(t *testing.T) { - planner := &EdgeConnectPlanner{} - - current := &InstanceState{ - Name: "test-instance", - FlavorName: "small", - CloudletName: "oldcloudlet", - CloudletOrg: "oldorg", - } - - desired := &InstanceState{ - Name: "test-instance", - FlavorName: "medium", - CloudletName: "newcloudlet", - CloudletOrg: "neworg", - } - - changes := planner.compareInstanceStates(current, desired) - assert.Len(t, changes, 3) - assert.Contains(t, changes[0], "Flavor changed") - assert.Contains(t, changes[1], "Cloudlet changed") - assert.Contains(t, changes[2], "Cloudlet org changed") - - // Test no changes - desired.FlavorName = "small" - desired.CloudletName = "oldcloudlet" - desired.CloudletOrg = "oldorg" - changes = planner.compareInstanceStates(current, desired) - assert.Empty(t, changes) -} - -func TestDeploymentPlanMethods(t *testing.T) { - plan := &DeploymentPlan{ - ConfigName: "test-plan", - AppAction: AppAction{ - Type: ActionCreate, - Desired: &AppState{Name: "test-app"}, - }, - InstanceActions: []InstanceAction{ - { - Type: ActionCreate, - Target: config.InfraTemplate{ - CloudletOrg: "org1", - CloudletName: "cloudlet1", - Region: "US", - }, - InstanceName: "instance1", - Desired: &InstanceState{Name: "instance1"}, - }, - { - Type: ActionUpdate, - Target: config.InfraTemplate{ - CloudletOrg: "org2", - CloudletName: "cloudlet2", - Region: "EU", - }, - InstanceName: "instance2", - Desired: &InstanceState{Name: "instance2"}, - }, - }, - } - - // Test IsEmpty - assert.False(t, plan.IsEmpty()) - - // Test GetTargetCloudlets - cloudlets := plan.GetTargetCloudlets() - assert.Len(t, cloudlets, 2) - assert.Contains(t, cloudlets, "org1:cloudlet1") - assert.Contains(t, cloudlets, "org2:cloudlet2") - - // Test GetTargetRegions - regions := plan.GetTargetRegions() - assert.Len(t, regions, 2) - assert.Contains(t, regions, "US") - assert.Contains(t, regions, "EU") - - // Test GenerateSummary - summary := plan.GenerateSummary() - assert.Contains(t, summary, "test-plan") - assert.Contains(t, summary, "CREATE application") - assert.Contains(t, summary, "CREATE 1 instance") - assert.Contains(t, summary, "UPDATE 1 instance") - - // Test Validate - err := plan.Validate() - assert.NoError(t, err) - - // Test validation failure - plan.AppAction.Desired = nil - err = plan.Validate() - assert.Error(t, err) - assert.Contains(t, err.Error(), "must have desired state") -} - -func TestEstimateDeploymentDuration(t *testing.T) { - planner := &EdgeConnectPlanner{} - - plan := &DeploymentPlan{ - AppAction: AppAction{Type: ActionCreate}, - InstanceActions: []InstanceAction{ - {Type: ActionCreate}, - {Type: ActionUpdate}, - }, - } - - duration := planner.estimateDeploymentDuration(plan) - assert.Greater(t, duration, time.Duration(0)) - assert.Less(t, duration, 10*time.Minute) // Reasonable upper bound - - // Test with no actions - emptyPlan := &DeploymentPlan{ - AppAction: AppAction{Type: ActionNone}, - InstanceActions: []InstanceAction{}, - } - - emptyDuration := planner.estimateDeploymentDuration(emptyPlan) - assert.Greater(t, emptyDuration, time.Duration(0)) - assert.Less(t, emptyDuration, duration) // Should be less than plan with actions -} - -func TestIsResourceNotFoundError(t *testing.T) { - tests := []struct { - name string - err error - expected bool - }{ - {"nil error", nil, false}, - {"not found error", &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true}, - {"does not exist error", &edgeconnect.APIError{Messages: []string{"App does not exist"}}, true}, - {"404 in message", &edgeconnect.APIError{Messages: []string{"HTTP 404 error"}}, true}, - {"other error", &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isResourceNotFoundError(tt.err) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestPlanErrorHandling(t *testing.T) { - mockClient := &MockEdgeConnectClient{} - planner := NewPlanner(mockClient) - testConfig := createTestConfig(t) - - // Mock API call to return a non-404 error - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) - - ctx := context.Background() - result, err := planner.Plan(ctx, testConfig) - - assert.Error(t, err) - assert.NotNil(t, result) - assert.NotNil(t, result.Error) - assert.Contains(t, err.Error(), "failed to query current app state") - - mockClient.AssertExpectations(t) -} diff --git a/internal/apply/types.go b/internal/apply/types.go deleted file mode 100644 index 6f7ef4e..0000000 --- a/internal/apply/types.go +++ /dev/null @@ -1,462 +0,0 @@ -// ABOUTME: Deployment planning types for EdgeConnect apply command with state management -// ABOUTME: Defines structures for deployment plans, actions, and state comparison results -package apply - -import ( - "fmt" - "strings" - "time" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" -) - -// SecurityRule defines network access rules (alias to SDK type for consistency) -type SecurityRule = edgeconnect.SecurityRule - -// ActionType represents the type of action to be performed -type ActionType string - -const ( - // ActionCreate indicates a resource needs to be created - ActionCreate ActionType = "CREATE" - // ActionUpdate indicates a resource needs to be updated - ActionUpdate ActionType = "UPDATE" - // ActionNone indicates no action is needed - ActionNone ActionType = "NONE" - // ActionDelete indicates a resource needs to be deleted (for rollback scenarios) - ActionDelete ActionType = "DELETE" -) - -// String returns the string representation of ActionType -func (a ActionType) String() string { - return string(a) -} - -// DeploymentPlan represents the complete deployment plan for a configuration -type DeploymentPlan struct { - // ConfigName is the name from metadata - ConfigName string - - // AppAction defines what needs to be done with the application - AppAction AppAction - - // InstanceActions defines what needs to be done with each instance - InstanceActions []InstanceAction - - // Summary provides a human-readable summary of the plan - Summary string - - // TotalActions is the count of all actions that will be performed - TotalActions int - - // EstimatedDuration is the estimated time to complete the deployment - EstimatedDuration time.Duration - - // CreatedAt timestamp when the plan was created - CreatedAt time.Time - - // DryRun indicates if this is a dry-run plan - DryRun bool -} - -// AppAction represents an action to be performed on an application -type AppAction struct { - // Type of action to perform - Type ActionType - - // Current state of the app (nil if doesn't exist) - Current *AppState - - // Desired state of the app - Desired *AppState - - // Changes describes what will change - Changes []string - - // Reason explains why this action is needed - Reason string - - // ManifestHash is the hash of the current manifest file - ManifestHash string - - // ManifestChanged indicates if the manifest content has changed - ManifestChanged bool -} - -// InstanceAction represents an action to be performed on an application instance -type InstanceAction struct { - // Type of action to perform - Type ActionType - - // Target infrastructure where the instance will be deployed - Target config.InfraTemplate - - // Current state of the instance (nil if doesn't exist) - Current *InstanceState - - // Desired state of the instance - Desired *InstanceState - - // Changes describes what will change - Changes []string - - // Reason explains why this action is needed - Reason string - - // InstanceName is the generated name for this instance - InstanceName string - - // Dependencies lists other instances this depends on - Dependencies []string -} - -// AppState represents the current state of an application -type AppState struct { - // Name of the application - Name string - - // Version of the application - Version string - - // Organization that owns the app - Organization string - - // Region where the app is deployed - Region string - - // ManifestHash is the stored hash of the manifest file - ManifestHash string - - // LastUpdated timestamp when the app was last modified - LastUpdated time.Time - - // Exists indicates if the app currently exists - Exists bool - - // AppType indicates whether this is a k8s or docker app - AppType AppType - - // OutboundConnections contains the required outbound network connections - OutboundConnections []SecurityRule -} - -// InstanceState represents the current state of an application instance -type InstanceState struct { - // Name of the instance - Name string - - // AppName that this instance belongs to - AppName string - - // AppVersion of the associated app - AppVersion string - - // Organization that owns the instance - Organization string - - // Region where the instance is deployed - Region string - - // CloudletOrg that hosts the cloudlet - CloudletOrg string - - // CloudletName where the instance is running - CloudletName string - - // FlavorName used for the instance - FlavorName string - - // State of the instance (e.g., "Ready", "Pending", "Error") - State string - - // PowerState of the instance - PowerState string - - // LastUpdated timestamp when the instance was last modified - LastUpdated time.Time - - // Exists indicates if the instance currently exists - Exists bool -} - -// AppType represents the type of application -type AppType string - -const ( - // AppTypeK8s represents a Kubernetes application - AppTypeK8s AppType = "k8s" - // AppTypeDocker represents a Docker application - AppTypeDocker AppType = "docker" -) - -// String returns the string representation of AppType -func (a AppType) String() string { - return string(a) -} - -// DeploymentSummary provides a high-level overview of the deployment plan -type DeploymentSummary struct { - // TotalActions is the total number of actions to be performed - TotalActions int - - // ActionCounts breaks down actions by type - ActionCounts map[ActionType]int - - // EstimatedDuration for the entire deployment - EstimatedDuration time.Duration - - // ResourceSummary describes the resources involved - ResourceSummary ResourceSummary - - // Warnings about potential issues - Warnings []string -} - -// ResourceSummary provides details about resources in the deployment -type ResourceSummary struct { - // AppsToCreate number of apps that will be created - AppsToCreate int - - // AppsToUpdate number of apps that will be updated - AppsToUpdate int - - // InstancesToCreate number of instances that will be created - InstancesToCreate int - - // InstancesToUpdate number of instances that will be updated - InstancesToUpdate int - - // CloudletsAffected number of unique cloudlets involved - CloudletsAffected int - - // RegionsAffected number of unique regions involved - RegionsAffected int -} - -// PlanResult represents the result of a deployment planning operation -type PlanResult struct { - // Plan is the generated deployment plan - Plan *DeploymentPlan - - // Error if planning failed - Error error - - // Warnings encountered during planning - Warnings []string -} - -// ExecutionResult represents the result of executing a deployment plan -type ExecutionResult struct { - // Plan that was executed - Plan *DeploymentPlan - - // Success indicates if the deployment was successful - Success bool - - // CompletedActions lists actions that were successfully completed - CompletedActions []ActionResult - - // FailedActions lists actions that failed - FailedActions []ActionResult - - // Error that caused the deployment to fail (if any) - Error error - - // Duration taken to execute the plan - Duration time.Duration - - // RollbackPerformed indicates if rollback was executed - RollbackPerformed bool - - // RollbackSuccess indicates if rollback was successful - RollbackSuccess bool -} - -// ActionResult represents the result of executing a single action -type ActionResult struct { - // Type of action that was attempted - Type ActionType - - // Target describes what was being acted upon - Target string - - // Success indicates if the action succeeded - Success bool - - // Error if the action failed - Error error - - // Duration taken to complete the action - Duration time.Duration - - // Details provides additional information about the action - Details string -} - -// IsEmpty returns true if the deployment plan has no actions to perform -func (dp *DeploymentPlan) IsEmpty() bool { - if dp.AppAction.Type != ActionNone { - return false - } - - for _, action := range dp.InstanceActions { - if action.Type != ActionNone { - return false - } - } - - return true -} - -// HasErrors returns true if the plan contains any error conditions -func (dp *DeploymentPlan) HasErrors() bool { - // Check for conflicting actions or invalid states - return false // Implementation would check for various error conditions -} - -// GetTargetCloudlets returns a list of unique cloudlets that will be affected -func (dp *DeploymentPlan) GetTargetCloudlets() []string { - cloudletSet := make(map[string]bool) - var cloudlets []string - - for _, action := range dp.InstanceActions { - if action.Type != ActionNone { - key := fmt.Sprintf("%s:%s", action.Target.CloudletOrg, action.Target.CloudletName) - if !cloudletSet[key] { - cloudletSet[key] = true - cloudlets = append(cloudlets, key) - } - } - } - - return cloudlets -} - -// GetTargetRegions returns a list of unique regions that will be affected -func (dp *DeploymentPlan) GetTargetRegions() []string { - regionSet := make(map[string]bool) - var regions []string - - for _, action := range dp.InstanceActions { - if action.Type != ActionNone && !regionSet[action.Target.Region] { - regionSet[action.Target.Region] = true - regions = append(regions, action.Target.Region) - } - } - - return regions -} - -// GenerateSummary creates a human-readable summary of the deployment plan -func (dp *DeploymentPlan) GenerateSummary() string { - if dp.IsEmpty() { - return "No changes required - configuration matches current state" - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName)) - - // App actions - if dp.AppAction.Type != ActionNone { - sb.WriteString(fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name)) - if len(dp.AppAction.Changes) > 0 { - for _, change := range dp.AppAction.Changes { - sb.WriteString(fmt.Sprintf(" - %s\n", change)) - } - } - } - - // Instance actions - createCount := 0 - updateActions := []InstanceAction{} - for _, action := range dp.InstanceActions { - switch action.Type { - case ActionCreate: - createCount++ - case ActionUpdate: - updateActions = append(updateActions, action) - } - } - - if createCount > 0 { - sb.WriteString(fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets()))) - } - - if len(updateActions) > 0 { - sb.WriteString(fmt.Sprintf("- UPDATE %d instance(s)\n", len(updateActions))) - for _, action := range updateActions { - if len(action.Changes) > 0 { - sb.WriteString(fmt.Sprintf(" - Instance '%s':\n", action.InstanceName)) - for _, change := range action.Changes { - sb.WriteString(fmt.Sprintf(" - %s\n", change)) - } - } - } - } - - sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String())) - - return sb.String() -} - -// Validate checks if the deployment plan is valid and safe to execute -func (dp *DeploymentPlan) Validate() error { - if dp.ConfigName == "" { - return fmt.Errorf("deployment plan must have a config name") - } - - // Validate app action - if dp.AppAction.Type != ActionNone && dp.AppAction.Desired == nil { - return fmt.Errorf("app action of type %s must have desired state", dp.AppAction.Type) - } - - // Validate instance actions - for i, action := range dp.InstanceActions { - if action.Type != ActionNone { - if action.Desired == nil { - return fmt.Errorf("instance action %d of type %s must have desired state", i, action.Type) - } - if action.InstanceName == "" { - return fmt.Errorf("instance action %d must have an instance name", i) - } - } - } - - return nil -} - -// Clone creates a deep copy of the deployment plan -func (dp *DeploymentPlan) Clone() *DeploymentPlan { - clone := &DeploymentPlan{ - ConfigName: dp.ConfigName, - Summary: dp.Summary, - TotalActions: dp.TotalActions, - EstimatedDuration: dp.EstimatedDuration, - CreatedAt: dp.CreatedAt, - DryRun: dp.DryRun, - AppAction: dp.AppAction, // Struct copy is sufficient for this use case - } - - // Deep copy instance actions - clone.InstanceActions = make([]InstanceAction, len(dp.InstanceActions)) - copy(clone.InstanceActions, dp.InstanceActions) - - return clone -} - -// convertNetworkRules converts config network rules to EdgeConnect SecurityRules -func convertNetworkRules(network *config.NetworkConfig) []edgeconnect.SecurityRule { - rules := make([]edgeconnect.SecurityRule, len(network.OutboundConnections)) - - for i, conn := range network.OutboundConnections { - rules[i] = edgeconnect.SecurityRule{ - Protocol: conn.Protocol, - PortRangeMin: conn.PortRangeMin, - PortRangeMax: conn.PortRangeMax, - RemoteCIDR: conn.RemoteCIDR, - } - } - - return rules -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 75c1747..b6daf1e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -13,7 +13,7 @@ func TestGetDeploymentType(t *testing.T) { K8sApp: &K8sApp{}, }, } - assert.Equal(t, "kubernetes", k8sConfig.GetDeploymentType()) + assert.Equal(t, "docker", k8sConfig.GetDeploymentType()) // Test docker app dockerConfig := &EdgeConnectConfig{ diff --git a/internal/apply/manager.go b/internal/core/apply/manager.go similarity index 90% rename from internal/apply/manager.go rename to internal/core/apply/manager.go index 45477ab..100c622 100644 --- a/internal/apply/manager.go +++ b/internal/core/apply/manager.go @@ -8,7 +8,8 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" ) // ResourceManagerInterface defines the interface for resource management @@ -25,7 +26,8 @@ type ResourceManagerInterface interface { // EdgeConnectResourceManager implements resource management for EdgeConnect type EdgeConnectResourceManager struct { - client EdgeConnectClientInterface + appRepo driven.AppRepository + appInstRepo driven.AppInstanceRepository parallelLimit int rollbackOnFail bool logger Logger @@ -66,14 +68,15 @@ func DefaultResourceManagerOptions() ResourceManagerOptions { } // NewResourceManager creates a new EdgeConnect resource manager -func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface { +func NewResourceManager(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface { options := DefaultResourceManagerOptions() for _, opt := range opts { opt(&options) } return &EdgeConnectResourceManager{ - client: client, + appRepo: appRepo, + appInstRepo: appInstRepo, parallelLimit: options.ParallelLimit, rollbackOnFail: options.RollbackOnFail, logger: options.Logger, @@ -133,7 +136,7 @@ func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan strategyConfig := rm.strategyConfig strategyConfig.ParallelOperations = rm.parallelLimit > 1 - factory := NewStrategyFactory(rm.client, strategyConfig, rm.logger) + factory := NewStrategyFactory(rm.appRepo, rm.appInstRepo, strategyConfig, rm.logger) strategy, err := factory.CreateStrategy(strategyName) if err != nil { result := &ExecutionResult{ @@ -190,8 +193,8 @@ func (rm *EdgeConnectResourceManager) ValidatePrerequisites(ctx context.Context, } // Validate that we have required client capabilities - if rm.client == nil { - return fmt.Errorf("EdgeConnect client is not configured") + if rm.appRepo == nil || rm.appInstRepo == nil { + return fmt.Errorf("repositories are not configured") } rm.logf("Prerequisites validation passed") @@ -250,13 +253,13 @@ func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context, // rollbackApp deletes an application that was created func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error { - appKey := edgeconnect.AppKey{ + appKey := domain.AppKey{ Organization: plan.AppAction.Desired.Organization, Name: plan.AppAction.Desired.Name, Version: plan.AppAction.Desired.Version, } - return rm.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region) + return rm.appRepo.DeleteApp(ctx, plan.AppAction.Desired.Region, appKey) } // rollbackInstance deletes an instance that was created @@ -264,15 +267,15 @@ func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, acti // Find the instance action to get the details for _, instanceAction := range plan.InstanceActions { if instanceAction.InstanceName == action.Target { - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := domain.AppInstanceKey{ Organization: plan.AppAction.Desired.Organization, Name: instanceAction.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: domain.CloudletKey{ Organization: instanceAction.Target.CloudletOrg, Name: instanceAction.Target.CloudletName, }, } - return rm.client.DeleteAppInstance(ctx, instanceKey, instanceAction.Target.Region) + return rm.appInstRepo.DeleteAppInstance(ctx, instanceAction.Target.Region, instanceKey) } } return fmt.Errorf("instance action not found for rollback: %s", action.Target) @@ -283,4 +286,4 @@ func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) { if rm.logger != nil { rm.logger.Printf("[ResourceManager] "+format, v...) } -} +} \ No newline at end of file diff --git a/internal/apply/manager_test.go b/internal/core/apply/manager_test.go similarity index 99% rename from internal/apply/manager_test.go rename to internal/core/apply/manager_test.go index 6060a37..7237251 100644 --- a/internal/apply/manager_test.go +++ b/internal/core/apply/manager_test.go @@ -11,7 +11,7 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/edgeconnect" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/internal/core/apply/mocks_test.go b/internal/core/apply/mocks_test.go new file mode 100644 index 0000000..a1dc7b4 --- /dev/null +++ b/internal/core/apply/mocks_test.go @@ -0,0 +1,139 @@ +package apply + +import ( + "context" + "fmt" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" + "github.com/stretchr/testify/mock" +) + +// MockAppRepository is a mock implementation of driven.AppRepository +type MockAppRepository struct { + mock.Mock +} + +func (m *MockAppRepository) CreateApp(ctx context.Context, region string, app *domain.App) error { + args := m.Called(ctx, region, app) + return args.Error(0) +} + +func (m *MockAppRepository) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) { + args := m.Called(ctx, region, appKey) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.App), args.Error(1) +} + +func (m *MockAppRepository) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) { + args := m.Called(ctx, region, appKey) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.App), args.Error(1) +} + +func (m *MockAppRepository) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error { + args := m.Called(ctx, region, appKey) + return args.Error(0) +} + +func (m *MockAppRepository) UpdateApp(ctx context.Context, region string, app *domain.App) error { + args := m.Called(ctx, region, app) + return args.Error(0) +} + +// MockAppInstanceRepository is a mock implementation of driven.AppInstanceRepository +type MockAppInstanceRepository struct { + mock.Mock +} + +func (m *MockAppInstanceRepository) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + args := m.Called(ctx, region, appInst) + return args.Error(0) +} + +func (m *MockAppInstanceRepository) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) { + args := m.Called(ctx, region, appInstKey) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.AppInstance), args.Error(1) +} + +func (m *MockAppInstanceRepository) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) { + args := m.Called(ctx, region, appInstKey) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.AppInstance), args.Error(1) +} + +func (m *MockAppInstanceRepository) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + args := m.Called(ctx, region, appInstKey) + return args.Error(0) +} + +func (m *MockAppInstanceRepository) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + args := m.Called(ctx, region, appInst) + return args.Error(0) +} + +func (m *MockAppInstanceRepository) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + args := m.Called(ctx, region, appInstKey) + return args.Error(0) +} + +// MockConfigRepository is a mock implementation of driven.ConfigRepository +type MockConfigRepository struct { + mock.Mock +} + +func (m *MockConfigRepository) ParseFile(path string) (*config.EdgeConnectConfig, string, error) { + args := m.Called(path) + if args.Get(0) == nil { + return nil, args.String(1), args.Error(2) + } + return args.Get(0).(*config.EdgeConnectConfig), args.String(1), args.Error(2) +} + +func (m *MockConfigRepository) Validate(cfg *config.EdgeConnectConfig) error { + args := m.Called(cfg) + return args.Error(0) +} + +// NewTestPlanner creates a planner with mock repositories for testing +func NewTestPlanner(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) Planner { + if appRepo == nil { + appRepo = new(MockAppRepository) + } + if appInstRepo == nil { + appInstRepo = new(MockAppInstanceRepository) + } + return NewPlanner(appRepo, appInstRepo) +} + +// NewTestResourceManager creates a resource manager with mock repositories for testing +func NewTestResourceManager(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) ResourceManagerInterface { + if appRepo == nil { + appRepo = new(MockAppRepository) + } + if appInstRepo == nil { + appInstRepo = new(MockAppInstanceRepository) + } + return NewResourceManager(appRepo, appInstRepo) +} + +// NewTestStrategyFactory creates a strategy factory with mock repositories for testing +func NewTestStrategyFactory(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) *StrategyFactory { + if appRepo == nil { + appRepo = new(MockAppRepository) + } + if appInstRepo == nil { + appInstRepo = new(MockAppInstanceRepository) + } + return NewStrategyFactory(appRepo, appInstRepo, DefaultStrategyConfig(), nil) +} diff --git a/internal/apply/planner.go b/internal/core/apply/planner.go similarity index 90% rename from internal/apply/planner.go rename to internal/core/apply/planner.go index 1cbc58d..bc70c8c 100644 --- a/internal/apply/planner.go +++ b/internal/core/apply/planner.go @@ -12,21 +12,10 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" ) -// EdgeConnectClientInterface defines the methods needed for deployment planning -type EdgeConnectClientInterface interface { - ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) - CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error - UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error - DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error - ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) - CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error - UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error - DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error -} - // Planner defines the interface for deployment planning type Planner interface { // Plan analyzes the configuration and current state to generate a deployment plan @@ -67,13 +56,15 @@ func DefaultPlanOptions() PlanOptions { // EdgeConnectPlanner implements the Planner interface for EdgeConnect type EdgeConnectPlanner struct { - client EdgeConnectClientInterface + appRepo driven.AppRepository + appInstRepo driven.AppInstanceRepository } // NewPlanner creates a new EdgeConnect deployment planner -func NewPlanner(client EdgeConnectClientInterface) Planner { +func NewPlanner(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) Planner { return &EdgeConnectPlanner{ - client: client, + appRepo: appRepo, + appInstRepo: appInstRepo, } } @@ -148,9 +139,9 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E // Extract outbound connections from config if config.Spec.Network != nil { - desired.OutboundConnections = make([]SecurityRule, len(config.Spec.Network.OutboundConnections)) + desired.OutboundConnections = make([]domain.SecurityRule, len(config.Spec.Network.OutboundConnections)) for i, conn := range config.Spec.Network.OutboundConnections { - desired.OutboundConnections[i] = SecurityRule{ + desired.OutboundConnections[i] = domain.SecurityRule{ Protocol: conn.Protocol, PortRangeMin: conn.PortRangeMin, PortRangeMax: conn.PortRangeMax, @@ -285,13 +276,13 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - appKey := edgeconnect.AppKey{ + appKey := domain.AppKey{ Organization: desired.Organization, Name: desired.Name, Version: desired.Version, } - app, err := p.client.ShowApp(timeoutCtx, appKey, desired.Region) + app, err := p.appRepo.ShowApp(timeoutCtx, desired.Region, appKey) if err != nil { return nil, err } @@ -321,9 +312,9 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap } // Extract outbound connections from the app - current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections)) + current.OutboundConnections = make([]domain.SecurityRule, len(app.RequiredOutboundConnections)) for i, conn := range app.RequiredOutboundConnections { - current.OutboundConnections[i] = SecurityRule{ + current.OutboundConnections[i] = domain.SecurityRule{ Protocol: conn.Protocol, PortRangeMin: conn.PortRangeMin, PortRangeMax: conn.PortRangeMax, @@ -339,16 +330,16 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := domain.AppInstanceKey{ Organization: desired.Organization, Name: desired.Name, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: domain.CloudletKey{ Organization: desired.CloudletOrg, Name: desired.CloudletName, }, } - instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region) + instance, err := p.appInstRepo.ShowAppInstance(timeoutCtx, desired.Region, instanceKey) if err != nil { return nil, err } @@ -405,10 +396,10 @@ func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]str } // compareOutboundConnections compares two sets of outbound connections for equality -func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) []string { +func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []domain.SecurityRule) []string { var changes []string - makeMap := func(rules []SecurityRule) map[string]SecurityRule { - m := make(map[string]SecurityRule, len(rules)) + makeMap := func(rules []domain.SecurityRule) map[string]domain.SecurityRule { + m := make(map[string]domain.SecurityRule, len(rules)) for _, r := range rules { key := fmt.Sprintf("%s:%d-%d:%s", strings.ToLower(r.Protocol), @@ -552,4 +543,4 @@ func max(a, b time.Duration) time.Duration { // getInstanceName generates the instance name following the pattern: appName-appVersion-instance func getInstanceName(appName, appVersion string) string { return fmt.Sprintf("%s-%s-instance", appName, appVersion) -} +} \ No newline at end of file diff --git a/internal/core/apply/planner_test.go b/internal/core/apply/planner_test.go new file mode 100644 index 0000000..b6b461c --- /dev/null +++ b/internal/core/apply/planner_test.go @@ -0,0 +1,757 @@ +package apply + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestPlanner_Plan_CreateApp(t *testing.T) { + mockAppRepo := new(MockAppRepository) + mockAppInstRepo := new(MockAppInstanceRepository) + planner := NewTestPlanner(mockAppRepo, mockAppInstRepo) + + testConfig := &config.EdgeConnectConfig{ + Metadata: config.Metadata{ + Name: "test-app", + AppVersion: "1.0.0", + Organization: "test-org", + }, + Spec: config.AppSpec{ + Deployment: "kubernetes", + InfraTemplate: []config.InfraTemplate{ + { + Region: "us-west", + CloudletOrg: "cloudlet-org", + CloudletName: "cloudlet-name", + FlavorName: "m4.small", + }, + }, + }, + } + + // Mock app not found + mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }).Return(nil, fmt.Errorf("resource not found")) + + result, err := planner.Plan(context.Background(), testConfig) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.NotNil(t, result.Plan) + assert.Equal(t, ActionCreate, result.Plan.AppAction.Type) + assert.Equal(t, "Application does not exist", result.Plan.AppAction.Reason) + assert.Len(t, result.Plan.InstanceActions, 1) + assert.Equal(t, ActionCreate, result.Plan.InstanceActions[0].Type) + assert.Equal(t, "Instance does not exist", result.Plan.InstanceActions[0].Reason) + assert.Equal(t, 2, result.Plan.TotalActions) + + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) +} + +func TestPlanner_Plan_UpdateApp(t *testing.T) { + mockAppRepo := new(MockAppRepository) + mockAppInstRepo := new(MockAppInstanceRepository) + planner := NewTestPlanner(mockAppRepo, mockAppInstRepo) + + testConfig := &config.EdgeConnectConfig{ + Metadata: config.Metadata{ + Name: "test-app", + AppVersion: "1.0.0", + Organization: "test-org", + }, + Spec: config.AppSpec{ + Deployment: "kubernetes", + InfraTemplate: []config.InfraTemplate{ + { + Region: "us-west", + CloudletOrg: "cloudlet-org", + CloudletName: "cloudlet-name", + FlavorName: "m4.small", + }, + }, + Network: &config.NetworkConfig{ + OutboundConnections: []config.OutboundConnection{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + }, + }, + }, + } + + existingApp := &domain.App{ + Key: domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }, + Deployment: "kubernetes", + RequiredOutboundConnections: []domain.SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 8080, + PortRangeMax: 8080, + RemoteCIDR: "0.0.0.0/0", + }, + }, + } + + // Mock app found + mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }).Return(existingApp, nil) + + // Mock instance not found + mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{ + Organization: "test-org", + Name: "test-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org", + Name: "cloudlet-name", + }, + }).Return(nil, fmt.Errorf("resource not found")) + + result, err := planner.Plan(context.Background(), testConfig) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.NotNil(t, result.Plan) + assert.Equal(t, ActionUpdate, result.Plan.AppAction.Type) + assert.Contains(t, result.Plan.AppAction.Reason, "Application configuration has changed") + assert.Len(t, result.Plan.InstanceActions, 1) + assert.Equal(t, ActionCreate, result.Plan.InstanceActions[0].Type) + assert.Equal(t, 2, result.Plan.TotalActions) + + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) +} + +func TestPlanner_Plan_NoChange(t *testing.T) { + mockAppRepo := new(MockAppRepository) + mockAppInstRepo := new(MockAppInstanceRepository) + planner := NewTestPlanner(mockAppRepo, mockAppInstRepo) + + testConfig := &config.EdgeConnectConfig{ + Metadata: config.Metadata{ + Name: "test-app", + AppVersion: "1.0.0", + Organization: "test-org", + }, + Spec: config.AppSpec{ + Deployment: "kubernetes", + InfraTemplate: []config.InfraTemplate{ + { + Region: "us-west", + CloudletOrg: "cloudlet-org", + CloudletName: "cloudlet-name", + FlavorName: "m4.small", + }, + }, + Network: &config.NetworkConfig{ + OutboundConnections: []config.OutboundConnection{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + }, + }, + }, + } + + existingApp := &domain.App{ + Key: domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }, + Deployment: "kubernetes", + RequiredOutboundConnections: []domain.SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + }, + } + + existingInstance := &domain.AppInstance{ + Key: domain.AppInstanceKey{ + Organization: "test-org", + Name: "test-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org", + Name: "cloudlet-name", + }, + }, + AppKey: domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }, + Flavor: domain.Flavor{Name: "m4.small"}, + } + + // Mock app found + mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }).Return(existingApp, nil) + + // Mock instance found + mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{ + Organization: "test-org", Name: "test-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org", + Name: "cloudlet-name", + }, + }).Return(existingInstance, nil) + + result, err := planner.Plan(context.Background(), testConfig) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.NotNil(t, result.Plan) + assert.Equal(t, ActionNone, result.Plan.AppAction.Type) + assert.Equal(t, ActionNone, result.Plan.InstanceActions[0].Type) + assert.Equal(t, 0, result.Plan.TotalActions) + + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) +} + +func TestPlanner_Plan_SkipStateCheck(t *testing.T) { + mockAppRepo := new(MockAppRepository) + mockAppInstRepo := new(MockAppInstanceRepository) + planner := NewTestPlanner(mockAppRepo, mockAppInstRepo) + + testConfig := &config.EdgeConnectConfig{ + Metadata: config.Metadata{ + Name: "test-app", + AppVersion: "1.0.0", + Organization: "test-org", + }, + Spec: config.AppSpec{ + Deployment: "kubernetes", + InfraTemplate: []config.InfraTemplate{ + { + Region: "us-west", + CloudletOrg: "cloudlet-org", + CloudletName: "cloudlet-name", + FlavorName: "m4.small", + }, + }, + }, + } + + opts := DefaultPlanOptions() + opts.SkipStateCheck = true + + result, err := planner.PlanWithOptions(context.Background(), testConfig, opts) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.NotNil(t, result.Plan) + assert.Equal(t, ActionCreate, result.Plan.AppAction.Type) + assert.Equal(t, "Creating app (state check skipped)", result.Plan.AppAction.Reason) + assert.Len(t, result.Plan.InstanceActions, 1) + assert.Equal(t, ActionCreate, result.Plan.InstanceActions[0].Type) + assert.Equal(t, "Creating instance (state check skipped)", result.Plan.InstanceActions[0].Reason) + assert.Equal(t, 2, result.Plan.TotalActions) + + mockAppRepo.AssertNotCalled(t, "ShowApp", mock.Anything, mock.Anything, mock.Anything) + mockAppInstRepo.AssertNotCalled(t, "ShowAppInstance", mock.Anything, mock.Anything, mock.Anything) +} + +func TestPlanner_Plan_ManifestHashChange(t *testing.T) { + mockAppRepo := new(MockAppRepository) + mockAppInstRepo := new(MockAppInstanceRepository) + planner := NewTestPlanner(mockAppRepo, mockAppInstRepo) + + // Create a temporary manifest file + tempDir := t.TempDir() + manifestPath := filepath.Join(tempDir, "manifest.yaml") + err := os.WriteFile(manifestPath, []byte("new manifest content"), 0644) + assert.NoError(t, err) + + testConfig := &config.EdgeConnectConfig{ + Metadata: config.Metadata{ + Name: "test-app", + AppVersion: "1.0.0", + Organization: "test-org", + }, + Spec: config.AppSpec{ + Deployment: "kubernetes", + Manifest: manifestPath, + InfraTemplate: []config.InfraTemplate{ + { + Region: "us-west", + CloudletOrg: "cloudlet-org", + CloudletName: "cloudlet-name", + FlavorName: "m4.small", + }, + }, + }, + } + + existingApp := &domain.App{ + Key: domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }, + Deployment: "kubernetes", + DeploymentManifest: "old manifest content", // Different manifest + } + + // Mock app found + mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }).Return(existingApp, nil) + + // Mock instance found (no change) + mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{ + Organization: "test-org", + Name: "test-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org", + Name: "cloudlet-name", + }, + }).Return(&domain.AppInstance{ + Key: domain.AppInstanceKey{ + Organization: "test-org", + Name: "test-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org", + Name: "cloudlet-name", + }, + }, + AppKey: domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }, + Flavor: domain.Flavor{Name: "m4.small"}, + }, nil) + + result, err := planner.Plan(context.Background(), testConfig) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.NotNil(t, result.Plan) + assert.Equal(t, ActionUpdate, result.Plan.AppAction.Type) + assert.True(t, result.Plan.AppAction.ManifestChanged) + assert.Contains(t, result.Plan.AppAction.Reason, "Application configuration has changed") + assert.Contains(t, result.Warnings, "Manifest file has changed - instances may need to be recreated") + assert.Equal(t, 1, result.Plan.TotalActions) // Only app update, instance is ActionNone + + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) +} + +func TestPlanner_Plan_InstanceFlavorChange(t *testing.T) { + mockAppRepo := new(MockAppRepository) + mockAppInstRepo := new(MockAppInstanceRepository) + planner := NewTestPlanner(mockAppRepo, mockAppInstRepo) + + testConfig := &config.EdgeConnectConfig{ + Metadata: config.Metadata{ + Name: "test-app", + AppVersion: "1.0.0", + Organization: "test-org", + }, + Spec: config.AppSpec{ + Deployment: "kubernetes", + InfraTemplate: []config.InfraTemplate{ + { + Region: "us-west", + CloudletOrg: "cloudlet-org", + CloudletName: "cloudlet-name", + FlavorName: "m4.large", // Changed flavor + }, + }, + }, + } + + existingApp := &domain.App{ + Key: domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }, + Deployment: "kubernetes", + } + + existingInstance := &domain.AppInstance{ + Key: domain.AppInstanceKey{ + Organization: "test-org", + Name: "test-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org", + Name: "cloudlet-name", + }, + }, + AppKey: domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }, + Flavor: domain.Flavor{Name: "m4.small"}, // Old flavor + } + + // Mock app found + mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }).Return(existingApp, nil) + + // Mock instance found + mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{ + Organization: "test-org", + Name: "test-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org", + Name: "cloudlet-name", + }, + }).Return(existingInstance, nil) + + result, err := planner.Plan(context.Background(), testConfig) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.NotNil(t, result.Plan) + assert.Equal(t, ActionNone, result.Plan.AppAction.Type) + assert.Len(t, result.Plan.InstanceActions, 1) + assert.Equal(t, ActionUpdate, result.Plan.InstanceActions[0].Type) + assert.Contains(t, result.Plan.InstanceActions[0].Reason, "Instance configuration has changed") + assert.Contains(t, result.Plan.InstanceActions[0].Changes, "Flavor changed: m4.small -> m4.large") + assert.Equal(t, 1, result.Plan.TotalActions) + + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) +} + +func TestPlanner_Plan_MultipleInstances(t *testing.T) { + mockAppRepo := new(MockAppRepository) + mockAppInstRepo := new(MockAppInstanceRepository) + planner := NewTestPlanner(mockAppRepo, mockAppInstRepo) + + testConfig := &config.EdgeConnectConfig{ + Metadata: config.Metadata{ + Name: "multi-app", + AppVersion: "1.0.0", + Organization: "test-org", + }, + Spec: config.AppSpec{ + Deployment: "kubernetes", + InfraTemplate: []config.InfraTemplate{ + { + Region: "us-west", + CloudletOrg: "cloudlet-org-1", + CloudletName: "cloudlet-name-1", + FlavorName: "m4.small", + }, + { + Region: "us-east", + CloudletOrg: "cloudlet-org-2", + CloudletName: "cloudlet-name-2", + FlavorName: "m4.medium", + }, + }, + }, + } + + existingApp := &domain.App{ + Key: domain.AppKey{ + Organization: "test-org", + Name: "multi-app", + Version: "1.0.0", + }, + Deployment: "kubernetes", + } + + // Mock app found + mockAppRepo.On("ShowApp", mock.Anything, mock.AnythingOfType("string"), domain.AppKey{ + Organization: "test-org", + Name: "multi-app", + Version: "1.0.0", + }).Return(existingApp, nil) + + // Mock instance 1 not found + mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{ + Organization: "test-org", + Name: "multi-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org-1", + Name: "cloudlet-name-1", + }, + }).Return(nil, fmt.Errorf("resource not found")) + + // Mock instance 2 found with different flavor + mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-east", domain.AppInstanceKey{ + Organization: "test-org", + Name: "multi-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org-2", + Name: "cloudlet-name-2", + }, + }).Return(&domain.AppInstance{ + Key: domain.AppInstanceKey{ + Organization: "test-org", + Name: "multi-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org-2", + Name: "cloudlet-name-2", + }, + }, + AppKey: domain.AppKey{ + Organization: "test-org", + Name: "multi-app", + Version: "1.0.0", + }, + Flavor: domain.Flavor{Name: "m4.small"}, // Different flavor + }, nil) + + result, err := planner.Plan(context.Background(), testConfig) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.NotNil(t, result.Plan) + assert.Equal(t, ActionNone, result.Plan.AppAction.Type) + assert.Len(t, result.Plan.InstanceActions, 2) + assert.Equal(t, ActionCreate, result.Plan.InstanceActions[0].Type) // Instance 1 is new + assert.Equal(t, ActionUpdate, result.Plan.InstanceActions[1].Type) // Instance 2 has flavor change + assert.Contains(t, result.Plan.InstanceActions[1].Changes, "Flavor changed: m4.small -> m4.medium") + assert.Equal(t, 2, result.Plan.TotalActions) + + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) +} + +func TestPlanner_Plan_AppQueryError(t *testing.T) { + mockAppRepo := new(MockAppRepository) + mockAppInstRepo := new(MockAppInstanceRepository) + planner := NewTestPlanner(mockAppRepo, mockAppInstRepo) + + testConfig := &config.EdgeConnectConfig{ + Metadata: config.Metadata{ + Name: "test-app", + AppVersion: "1.0.0", + Organization: "test-org", + }, + Spec: config.AppSpec{ + Deployment: "kubernetes", + InfraTemplate: []config.InfraTemplate{ + { + Region: "us-west", + CloudletOrg: "cloudlet-org", + CloudletName: "cloudlet-name", + FlavorName: "m4.small", + }, + }, + }, + } + + // Mock app query error + mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }).Return(nil, fmt.Errorf("network error")) + + result, err := planner.Plan(context.Background(), testConfig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to query current app state: network error") + assert.Nil(t, result) + + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) +} + +func TestPlanner_Plan_InstanceQueryError(t *testing.T) { + mockAppRepo := new(MockAppRepository) + mockAppInstRepo := new(MockAppInstanceRepository) + planner := NewTestPlanner(mockAppRepo, mockAppInstRepo) + + testConfig := &config.EdgeConnectConfig{ + Metadata: config.Metadata{ + Name: "test-app", + AppVersion: "1.0.0", + Organization: "test-org", + }, + Spec: config.AppSpec{ + Deployment: "kubernetes", + InfraTemplate: []config.InfraTemplate{ + { + Region: "us-west", + CloudletOrg: "cloudlet-org", + CloudletName: "cloudlet-name", + FlavorName: "m4.small", + }, + }, + }, + } + + existingApp := &domain.App{ + Key: domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }, + Deployment: "kubernetes", + } + + // Mock app found + mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }).Return(existingApp, nil) + + // Mock instance query error + mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{ + Organization: "test-org", + Name: "test-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org", + Name: "cloudlet-name", + }, + }).Return(nil, fmt.Errorf("database error")) + + result, err := planner.Plan(context.Background(), testConfig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to query current instance state: database error") + assert.Nil(t, result) + + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) +} + +func TestPlanner_Plan_ManifestFileError(t *testing.T) { + mockAppRepo := new(MockAppRepository) + mockAppInstRepo := new(MockAppInstanceRepository) + planner := NewTestPlanner(mockAppRepo, mockAppInstRepo) + + testConfig := &config.EdgeConnectConfig{ + Metadata: config.Metadata{ + Name: "test-app", + AppVersion: "1.0.0", + Organization: "test-org", + }, + Spec: config.AppSpec{ + Deployment: "kubernetes", + Manifest: "/non/existent/path/manifest.yaml", // Invalid path + InfraTemplate: []config.InfraTemplate{ + { + Region: "us-west", + CloudletOrg: "cloudlet-org", + CloudletName: "cloudlet-name", + FlavorName: "m4.small", + }, + }, + }, + } + + result, err := planner.Plan(context.Background(), testConfig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to calculate manifest hash: failed to open manifest file") + assert.Nil(t, result) + + mockAppRepo.AssertNotCalled(t, "ShowApp", mock.Anything, mock.Anything, mock.Anything) + mockAppInstRepo.AssertNotCalled(t, "ShowAppInstance", mock.Anything, mock.Anything, mock.Anything) +} + +func TestPlanner_Plan_AppTypeChange(t *testing.T) { + mockAppRepo := new(MockAppRepository) + mockAppInstRepo := new(MockAppInstanceRepository) + planner := NewTestPlanner(mockAppRepo, mockAppInstRepo) + + testConfig := &config.EdgeConnectConfig{ + Metadata: config.Metadata{ + Name: "test-app", + AppVersion: "1.0.0", + Organization: "test-org", + }, + Spec: config.AppSpec{ + Deployment: "docker", // Desired is docker + InfraTemplate: []config.InfraTemplate{ + { + Region: "us-west", + CloudletOrg: "cloudlet-org", + CloudletName: "cloudlet-name", + FlavorName: "m4.small", + }, + }, + }, + } + + existingApp := &domain.App{ + Key: domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }, + Deployment: "kubernetes", // Current is kubernetes + } + + // Mock app found + mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }).Return(existingApp, nil) + + // Mock instance found (no change) + mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{ + Organization: "test-org", + Name: "test-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org", + Name: "cloudlet-name", + }, + }).Return(&domain.AppInstance{ + Key: domain.AppInstanceKey{ + Organization: "test-org", + Name: "test-app-1.0.0-instance", + CloudletKey: domain.CloudletKey{ + Organization: "cloudlet-org", + Name: "cloudlet-name", + }, + }, + AppKey: domain.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }, + Flavor: domain.Flavor{Name: "m4.small"}, + }, nil) + + result, err := planner.Plan(context.Background(), testConfig) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.NotNil(t, result.Plan) + assert.Equal(t, ActionUpdate, result.Plan.AppAction.Type) + assert.Contains(t, result.Plan.AppAction.Changes, "App type changed: KUBERNETES -> DOCKER") + assert.Equal(t, 1, result.Plan.TotalActions) // Only app update, instance is ActionNone + + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) +} \ No newline at end of file diff --git a/internal/apply/strategy.go b/internal/core/apply/strategy.go similarity index 86% rename from internal/apply/strategy.go rename to internal/core/apply/strategy.go index 8d32d2e..11f28ef 100644 --- a/internal/apply/strategy.go +++ b/internal/core/apply/strategy.go @@ -8,6 +8,7 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" ) // DeploymentStrategy represents the type of deployment strategy @@ -66,17 +67,19 @@ func DefaultStrategyConfig() StrategyConfig { // StrategyFactory creates deployment strategy executors type StrategyFactory struct { - config StrategyConfig - client EdgeConnectClientInterface - logger Logger + config StrategyConfig + appRepo driven.AppRepository + appInstRepo driven.AppInstanceRepository + logger Logger } // NewStrategyFactory creates a new strategy factory -func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *StrategyFactory { +func NewStrategyFactory(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, config StrategyConfig, logger Logger) *StrategyFactory { return &StrategyFactory{ - config: config, - client: client, - logger: logger, + config: config, + appRepo: appRepo, + appInstRepo: appInstRepo, + logger: logger, } } @@ -84,7 +87,7 @@ func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) { switch strategy { case StrategyRecreate: - return NewRecreateStrategy(f.client, f.config, f.logger), nil + return NewRecreateStrategy(f.appRepo, f.appInstRepo, f.config, f.logger), nil case StrategyBlueGreen: // TODO: Implement blue-green strategy return nil, fmt.Errorf("blue-green strategy not yet implemented") @@ -103,4 +106,4 @@ func (f *StrategyFactory) GetAvailableStrategies() []DeploymentStrategy { // StrategyBlueGreen, // TODO: Enable when implemented // StrategyRolling, // TODO: Enable when implemented } -} +} \ No newline at end of file diff --git a/internal/apply/strategy_recreate.go b/internal/core/apply/strategy_recreate.go similarity index 83% rename from internal/apply/strategy_recreate.go rename to internal/core/apply/strategy_recreate.go index b2302ca..1b92948 100644 --- a/internal/apply/strategy_recreate.go +++ b/internal/core/apply/strategy_recreate.go @@ -10,22 +10,25 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" ) // RecreateStrategy implements the recreate deployment strategy type RecreateStrategy struct { - client EdgeConnectClientInterface - config StrategyConfig - logger Logger + appRepo driven.AppRepository + appInstRepo driven.AppInstanceRepository + config StrategyConfig + logger Logger } // NewRecreateStrategy creates a new recreate strategy executor -func NewRecreateStrategy(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *RecreateStrategy { +func NewRecreateStrategy(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, config StrategyConfig, logger Logger) *RecreateStrategy { return &RecreateStrategy{ - client: client, - config: config, - logger: logger, + appRepo: appRepo, + appInstRepo: appInstRepo, + config: config, + logger: logger, } } @@ -183,13 +186,13 @@ func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentP r.logf("Phase 2: Deleting existing application") - appKey := edgeconnect.AppKey{ + appKey := domain.AppKey{ Organization: plan.AppAction.Desired.Organization, Name: plan.AppAction.Desired.Name, Version: plan.AppAction.Desired.Version, } - if err := r.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region); err != nil { + if err := r.appRepo.DeleteApp(ctx, plan.AppAction.Desired.Region, appKey); err != nil { result.FailedActions = append(result.FailedActions, ActionResult{ Type: ActionDelete, Target: plan.AppAction.Desired.Name, @@ -386,7 +389,12 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action } } - success, err := r.updateApplication(ctx, action, config, manifestContent) + var success bool + var err error + + // For recreate strategy, we always create the app + success, err = r.createApplication(ctx, action, config, manifestContent) + if success { result.Success = true result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name) @@ -407,16 +415,16 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action // deleteInstance deletes an instance (reuse existing logic from manager.go) func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) { - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := domain.AppInstanceKey{ Organization: action.Desired.Organization, Name: action.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: domain.CloudletKey{ Organization: action.Target.CloudletOrg, Name: action.Target.CloudletName, }, } - err := r.client.DeleteAppInstance(ctx, instanceKey, action.Target.Region) + err := r.appInstRepo.DeleteAppInstance(ctx, action.Target.Region, instanceKey) if err != nil { return false, fmt.Errorf("failed to delete instance: %w", err) } @@ -426,30 +434,27 @@ func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAc // createInstance creates an instance (extracted from manager.go logic) func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) { - instanceInput := &edgeconnect.NewAppInstanceInput{ - Region: action.Target.Region, - AppInst: edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ - Organization: action.Desired.Organization, - Name: action.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ - Organization: action.Target.CloudletOrg, - Name: action.Target.CloudletName, - }, - }, - AppKey: edgeconnect.AppKey{ - Organization: action.Desired.Organization, - Name: config.Metadata.Name, - Version: config.Metadata.AppVersion, - }, - Flavor: edgeconnect.Flavor{ - Name: action.Target.FlavorName, + appInst := &domain.AppInstance{ + Key: domain.AppInstanceKey{ + Organization: action.Desired.Organization, + Name: action.InstanceName, + CloudletKey: domain.CloudletKey{ + Organization: action.Target.CloudletOrg, + Name: action.Target.CloudletName, }, }, + AppKey: domain.AppKey{ + Organization: action.Desired.Organization, + Name: config.Metadata.Name, + Version: config.Metadata.AppVersion, + }, + Flavor: domain.Flavor{ + Name: action.Target.FlavorName, + }, } // Create the instance - if err := r.client.CreateAppInstance(ctx, instanceInput); err != nil { + if err := r.appInstRepo.CreateAppInstance(ctx, action.Target.Region, appInst); err != nil { return false, fmt.Errorf("failed to create instance: %w", err) } @@ -459,35 +464,30 @@ func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAc return true, nil } -// updateApplication creates/recreates an application (always uses CreateApp since we delete first) -func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) { - // Build the app create input - always create since recreate strategy deletes first - appInput := &edgeconnect.NewAppInput{ - Region: action.Desired.Region, - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ - Organization: action.Desired.Organization, - Name: action.Desired.Name, - Version: action.Desired.Version, - }, - Deployment: config.GetDeploymentType(), - ImageType: "ImageTypeDocker", - ImagePath: config.GetImagePath(), - AllowServerless: true, - DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, - ServerlessConfig: struct{}{}, - DeploymentManifest: manifestContent, - DeploymentGenerator: "kubernetes-basic", +// createApplication creates/recreates an application (always uses CreateApp since we delete first) +func (r *RecreateStrategy) createApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) { + app := &domain.App{ + Key: domain.AppKey{ + Organization: action.Desired.Organization, + Name: action.Desired.Name, + Version: action.Desired.Version, }, + Deployment: config.GetDeploymentType(), + ImagePath: config.GetImagePath(), + AllowServerless: true, + DefaultFlavor: domain.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, + ServerlessConfig: struct{}{}, + DeploymentManifest: manifestContent, + DeploymentGenerator: "kubernetes-basic", } // Add network configuration if specified if config.Spec.Network != nil { - appInput.App.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network) + app.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network) } // Create the application (recreate strategy always creates from scratch) - if err := r.client.CreateApp(ctx, appInput); err != nil { + if err := r.appRepo.CreateApp(ctx, action.Desired.Region, app); err != nil { return false, fmt.Errorf("failed to create application: %w", err) } @@ -497,9 +497,27 @@ func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppActi return true, nil } +// convertNetworkRules converts config.NetworkConfig to []domain.SecurityRule +func convertNetworkRules(network *config.NetworkConfig) []domain.SecurityRule { + if network == nil || len(network.OutboundConnections) == 0 { + return nil + } + + rules := make([]domain.SecurityRule, len(network.OutboundConnections)) + for i, conn := range network.OutboundConnections { + rules[i] = domain.SecurityRule{ + Protocol: conn.Protocol, + PortRangeMin: conn.PortRangeMin, + PortRangeMax: conn.PortRangeMax, + RemoteCIDR: conn.RemoteCIDR, + } + } + return rules +} + // logf logs a message if a logger is configured func (r *RecreateStrategy) logf(format string, v ...interface{}) { if r.logger != nil { r.logger.Printf("[RecreateStrategy] "+format, v...) } -} +} \ No newline at end of file diff --git a/internal/core/apply/types.go b/internal/core/apply/types.go new file mode 100644 index 0000000..82de329 --- /dev/null +++ b/internal/core/apply/types.go @@ -0,0 +1,161 @@ +// ABOUTME: Core types for EdgeConnect deployment planning and execution +// ABOUTME: Defines data structures for deployment plans, actions, and results +package apply + +import ( + "fmt" + "strings" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" +) + +// ActionType defines the type of action to be performed +type ActionType string + +const ( + ActionNone ActionType = "NONE" + ActionCreate ActionType = "CREATE" + ActionUpdate ActionType = "UPDATE" + ActionDelete ActionType = "DELETE" +) + +// AppType defines the type of application deployment +type AppType string + +const ( + AppTypeK8s AppType = "KUBERNETES" + AppTypeDocker AppType = "DOCKER" +) + +// AppState represents the desired or current state of an application +type AppState struct { + Name string + Version string + Organization string + Region string + AppType AppType + ManifestHash string + OutboundConnections []domain.SecurityRule + Exists bool + LastUpdated time.Time +} + +// InstanceState represents the desired or current state of an application instance +type InstanceState struct { + Name string + AppName string + AppVersion string + Organization string + Region string + CloudletOrg string + CloudletName string + FlavorName string + State string + PowerState string + Exists bool + LastUpdated time.Time +} + +// AppAction defines an action to be performed on an application +type AppAction struct { + Type ActionType + Desired *AppState + Current *AppState + ManifestHash string + ManifestChanged bool + Reason string + Changes []string +} + +// InstanceAction defines an action to be performed on an application instance +type InstanceAction struct { + Type ActionType + Target config.InfraTemplate + Desired *InstanceState + Current *InstanceState + InstanceName string + Reason string + Changes []string +} + +// DeploymentPlan represents a plan of actions to achieve the desired state +type DeploymentPlan struct { + ConfigName string + CreatedAt time.Time + DryRun bool + AppAction AppAction + InstanceActions []InstanceAction + TotalActions int + EstimatedDuration time.Duration + Summary string +} + +// IsEmpty returns true if the plan contains no actions +func (p *DeploymentPlan) IsEmpty() bool { + return p.AppAction.Type == ActionNone && len(p.InstanceActions) == 0 +} + +// Validate checks the validity of the deployment plan +func (p *DeploymentPlan) Validate() error { + if p.AppAction.Type == ActionNone && len(p.InstanceActions) == 0 { + return fmt.Errorf("deployment plan is empty") + } + // Add more validation rules as needed + return nil +} + +// GenerateSummary creates a human-readable summary of the plan +func (p *DeploymentPlan) GenerateSummary() string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Plan for '%s' (created: %s, dry-run: %t)\n", p.ConfigName, p.CreatedAt.Format(time.RFC3339), p.DryRun)) + sb.WriteString("--------------------------------------------------\n") + + if p.AppAction.Type != ActionNone { + sb.WriteString(fmt.Sprintf("Application '%s': %s - %s\n", p.AppAction.Desired.Name, p.AppAction.Type, p.AppAction.Reason)) + for _, change := range p.AppAction.Changes { + sb.WriteString(fmt.Sprintf(" - %s\n", change)) + } + } + + for _, action := range p.InstanceActions { + sb.WriteString(fmt.Sprintf("Instance '%s' on '%s': %s - %s\n", action.InstanceName, action.Target.CloudletName, action.Type, action.Reason)) + for _, change := range action.Changes { + sb.WriteString(fmt.Sprintf(" - %s\n", change)) + } + } + + sb.WriteString("--------------------------------------------------\n") + sb.WriteString(fmt.Sprintf("Total actions: %d, Estimated duration: %v\n", p.TotalActions, p.EstimatedDuration)) + return sb.String() +} + +// PlanResult holds the result of a planning operation +type PlanResult struct { + Plan *DeploymentPlan + Warnings []string + Error error +} + +// ExecutionResult holds the result of a deployment execution +type ExecutionResult struct { + Plan *DeploymentPlan + Success bool + Error error + Duration time.Duration + CompletedActions []ActionResult + FailedActions []ActionResult + RollbackPerformed bool + RollbackSuccess bool +} + +// ActionResult details the outcome of a single action +type ActionResult struct { + Type ActionType + Target string + Success bool + Error error + Details string + Duration time.Duration +} \ No newline at end of file diff --git a/internal/core/domain/domain.go b/internal/core/domain/domain.go new file mode 100644 index 0000000..3613c4b --- /dev/null +++ b/internal/core/domain/domain.go @@ -0,0 +1,79 @@ + +package domain + +// AppKey uniquely identifies an application +type AppKey struct { + Organization string + Name string + Version string +} + +// CloudletKey uniquely identifies a cloudlet +type CloudletKey struct { + Organization string + Name string +} + +// AppInstanceKey uniquely identifies an application instance +type AppInstanceKey struct { + Organization string + Name string + CloudletKey CloudletKey +} + +// Flavor defines resource allocation for instances +type Flavor struct { + Name string +} + +// SecurityRule defines network access rules +type SecurityRule struct { + PortRangeMax int + PortRangeMin int + Protocol string + RemoteCIDR string +} + +// App represents an application definition +type App struct { + Key AppKey + Deployment string + ImageType string + ImagePath string + AllowServerless bool + DefaultFlavor Flavor + ServerlessConfig interface{} + DeploymentGenerator string + DeploymentManifest string + RequiredOutboundConnections []SecurityRule + Fields []string +} + +// AppInstance represents a deployed application instance +type AppInstance struct { + Key AppInstanceKey + AppKey AppKey + Flavor Flavor + State string + PowerState string + Fields []string +} + +// Cloudlet represents edge infrastructure +type Cloudlet struct { + Key CloudletKey + Location Location + IpSupport string + NumDynamicIps int32 + State string + Flavor Flavor + PhysicalName string + Region string + NotifySrvAddr string +} + +// Location represents geographical coordinates +type Location struct { + Latitude float64 + Longitude float64 +} diff --git a/internal/core/ports/driven/app_repository.go b/internal/core/ports/driven/app_repository.go new file mode 100644 index 0000000..c45ac97 --- /dev/null +++ b/internal/core/ports/driven/app_repository.go @@ -0,0 +1,14 @@ +package driven + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" +) + +type AppRepository interface { + CreateApp(ctx context.Context, region string, app *domain.App) error + ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) + ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) + DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error + UpdateApp(ctx context.Context, region string, app *domain.App) error +} diff --git a/internal/core/ports/driven/cloudlet_repository.go b/internal/core/ports/driven/cloudlet_repository.go new file mode 100644 index 0000000..8916436 --- /dev/null +++ b/internal/core/ports/driven/cloudlet_repository.go @@ -0,0 +1,13 @@ +package driven + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" +) + +type CloudletRepository interface { + CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error + ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) + ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) + DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error +} diff --git a/internal/core/ports/driven/instance_repository.go b/internal/core/ports/driven/instance_repository.go new file mode 100644 index 0000000..899a9bc --- /dev/null +++ b/internal/core/ports/driven/instance_repository.go @@ -0,0 +1,15 @@ +package driven + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" +) + +type AppInstanceRepository interface { + CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error + ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) + ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) + DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error + UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error + RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error +} diff --git a/internal/core/ports/driving/app_service.go b/internal/core/ports/driving/app_service.go new file mode 100644 index 0000000..f57615c --- /dev/null +++ b/internal/core/ports/driving/app_service.go @@ -0,0 +1,14 @@ +package driving + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" +) + +type AppService interface { + CreateApp(ctx context.Context, region string, app *domain.App) error + ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) + ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) + DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error + UpdateApp(ctx context.Context, region string, app *domain.App) error +} diff --git a/internal/core/ports/driving/cloudlet_service.go b/internal/core/ports/driving/cloudlet_service.go new file mode 100644 index 0000000..7f83a7a --- /dev/null +++ b/internal/core/ports/driving/cloudlet_service.go @@ -0,0 +1,13 @@ +package driving + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" +) + +type CloudletService interface { + CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error + ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) + ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) + DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error +} diff --git a/internal/core/ports/driving/instance_service.go b/internal/core/ports/driving/instance_service.go new file mode 100644 index 0000000..ed99477 --- /dev/null +++ b/internal/core/ports/driving/instance_service.go @@ -0,0 +1,15 @@ +package driving + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" +) + +type AppInstanceService interface { + CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error + ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) + ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) + DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error + UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error + RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error +} diff --git a/internal/core/services/app_service.go b/internal/core/services/app_service.go new file mode 100644 index 0000000..f3782be --- /dev/null +++ b/internal/core/services/app_service.go @@ -0,0 +1,36 @@ +package services + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driving" +) + +type appService struct { + appRepo driven.AppRepository +} + +func NewAppService(appRepo driven.AppRepository) driving.AppService { + return &appService{appRepo: appRepo} +} + +func (s *appService) CreateApp(ctx context.Context, region string, app *domain.App) error { + return s.appRepo.CreateApp(ctx, region, app) +} + +func (s *appService) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) { + return s.appRepo.ShowApp(ctx, region, appKey) +} + +func (s *appService) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) { + return s.appRepo.ShowApps(ctx, region, appKey) +} + +func (s *appService) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error { + return s.appRepo.DeleteApp(ctx, region, appKey) +} + +func (s *appService) UpdateApp(ctx context.Context, region string, app *domain.App) error { + return s.appRepo.UpdateApp(ctx, region, app) +} diff --git a/internal/core/services/cloudlet_service.go b/internal/core/services/cloudlet_service.go new file mode 100644 index 0000000..0d014f4 --- /dev/null +++ b/internal/core/services/cloudlet_service.go @@ -0,0 +1,32 @@ +package services + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driving" +) + +type cloudletService struct { + cloudletRepo driven.CloudletRepository +} + +func NewCloudletService(cloudletRepo driven.CloudletRepository) driving.CloudletService { + return &cloudletService{cloudletRepo: cloudletRepo} +} + +func (s *cloudletService) CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error { + return s.cloudletRepo.CreateCloudlet(ctx, region, cloudlet) +} + +func (s *cloudletService) ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) { + return s.cloudletRepo.ShowCloudlet(ctx, region, cloudletKey) +} + +func (s *cloudletService) ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) { + return s.cloudletRepo.ShowCloudlets(ctx, region, cloudletKey) +} + +func (s *cloudletService) DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error { + return s.cloudletRepo.DeleteCloudlet(ctx, region, cloudletKey) +} diff --git a/internal/core/services/instance_service.go b/internal/core/services/instance_service.go new file mode 100644 index 0000000..8788500 --- /dev/null +++ b/internal/core/services/instance_service.go @@ -0,0 +1,40 @@ +package services + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driving" +) + +type appInstanceService struct { + appInstanceRepo driven.AppInstanceRepository +} + +func NewAppInstanceService(appInstanceRepo driven.AppInstanceRepository) driving.AppInstanceService { + return &appInstanceService{appInstanceRepo: appInstanceRepo} +} + +func (s *appInstanceService) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + return s.appInstanceRepo.CreateAppInstance(ctx, region, appInst) +} + +func (s *appInstanceService) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) { + return s.appInstanceRepo.ShowAppInstance(ctx, region, appInstKey) +} + +func (s *appInstanceService) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) { + return s.appInstanceRepo.ShowAppInstances(ctx, region, appInstKey) +} + +func (s *appInstanceService) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + return s.appInstanceRepo.DeleteAppInstance(ctx, region, appInstKey) +} + +func (s *appInstanceService) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + return s.appInstanceRepo.UpdateAppInstance(ctx, region, appInst) +} + +func (s *appInstanceService) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + return s.appInstanceRepo.RefreshAppInstance(ctx, region, appInstKey) +} diff --git a/main.go b/main.go index 9bc902d..14cfd1b 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,7 @@ package main -import "edp.buildth.ing/DevFW-CICD/edge-connect-client/cmd" +import "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/cli" func main() { - cmd.Execute() + cli.Execute() } diff --git a/sdk/examples/comprehensive/main.go b/sdk/examples/comprehensive/main.go index 616279f..4eeafb5 100644 --- a/sdk/examples/comprehensive/main.go +++ b/sdk/examples/comprehensive/main.go @@ -12,7 +12,8 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" ) func main() { @@ -86,6 +87,10 @@ type WorkflowConfig struct { } func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config WorkflowConfig) error { + var domainAppKey domain.AppKey + var domainInstanceKey domain.AppInstanceKey + var domainCloudletKey domain.CloudletKey + var domainAppInstKey domain.AppInstanceKey fmt.Println("═══ Phase 1: Application Management ═══") // 1. Create Application @@ -121,7 +126,22 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config }, } - if err := c.CreateApp(ctx, app); err != nil { + domainApp := &domain.App{ + Key: domain.AppKey{ + Organization: app.App.Key.Organization, + Name: app.App.Key.Name, + Version: app.App.Key.Version, + }, + Deployment: app.App.Deployment, + ImageType: app.App.ImageType, + ImagePath: app.App.ImagePath, + DefaultFlavor: domain.Flavor{Name: app.App.DefaultFlavor.Name}, + ServerlessConfig: app.App.ServerlessConfig, + AllowServerless: app.App.AllowServerless, + RequiredOutboundConnections: edgeconnect.ToDomainSecurityRules(app.App.RequiredOutboundConnections), + } + + if err := c.CreateApp(ctx, app.Region, domainApp); err != nil { return fmt.Errorf("failed to create app: %w", err) } fmt.Printf("✅ App created: %s/%s v%s\n", config.Organization, config.AppName, config.AppVersion) @@ -134,7 +154,12 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Version: config.AppVersion, } - appDetails, err := c.ShowApp(ctx, appKey, config.Region) + domainAppKey = domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } + appDetails, err := c.ShowApp(ctx, config.Region, domainAppKey) if err != nil { return fmt.Errorf("failed to show app: %w", err) } @@ -146,8 +171,8 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 3. List Applications in Organization fmt.Println("\n3️⃣ Listing applications in organization...") - filter := edgeconnect.AppKey{Organization: config.Organization} - apps, err := c.ShowApps(ctx, filter, config.Region) + filter := domain.AppKey{Organization: config.Organization} + apps, err := c.ShowApps(ctx, config.Region, filter) if err != nil { return fmt.Errorf("failed to list apps: %w", err) } @@ -176,7 +201,23 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config }, } - if err := c.CreateAppInstance(ctx, instance); err != nil { + domainAppInst := &domain.AppInstance{ + Key: domain.AppInstanceKey{ + Organization: instance.AppInst.Key.Organization, + Name: instance.AppInst.Key.Name, + CloudletKey: domain.CloudletKey{ + Organization: instance.AppInst.Key.CloudletKey.Organization, + Name: instance.AppInst.Key.CloudletKey.Name, + }, + }, + AppKey: domain.AppKey{ + Organization: instance.AppInst.AppKey.Organization, + Name: instance.AppInst.AppKey.Name, + Version: instance.AppInst.AppKey.Version, + }, + Flavor: domain.Flavor{Name: instance.AppInst.Flavor.Name}, + } + if err := c.CreateAppInstance(ctx, instance.Region, domainAppInst); err != nil { return fmt.Errorf("failed to create app instance: %w", err) } fmt.Printf("✅ App instance created: %s on cloudlet %s/%s\n", @@ -207,7 +248,8 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 6. List Application Instances fmt.Println("\n6️⃣ Listing application instances...") - instances, err := c.ShowAppInstances(ctx, edgeconnect.AppInstanceKey{Organization: config.Organization}, config.Region) + domainAppInstKey = domain.AppInstanceKey{Organization: config.Organization} + instances, err := c.ShowAppInstances(ctx, config.Region, domainAppInstKey) if err != nil { return fmt.Errorf("failed to list app instances: %w", err) } @@ -219,7 +261,15 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 7. Refresh Application Instance fmt.Println("\n7️⃣ Refreshing application instance...") - if err := c.RefreshAppInstance(ctx, instanceKey, config.Region); err != nil { + domainInstanceKey = domain.AppInstanceKey{ + Organization: instanceKey.Organization, + Name: instanceKey.Name, + CloudletKey: domain.CloudletKey{ + Organization: instanceKey.CloudletKey.Organization, + Name: instanceKey.CloudletKey.Name, + }, + } + if err := c.RefreshAppInstance(ctx, config.Region, domainInstanceKey); err != nil { return fmt.Errorf("failed to refresh app instance: %w", err) } fmt.Printf("✅ Instance refreshed: %s\n", config.InstanceName) @@ -233,7 +283,11 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Name: config.CloudletName, } - cloudlets, err := c.ShowCloudlets(ctx, cloudletKey, config.Region) + domainCloudletKey = domain.CloudletKey{ + Organization: cloudletKey.Organization, + Name: cloudletKey.Name, + } + cloudlets, err := c.ShowCloudlets(ctx, config.Region, domainCloudletKey) if err != nil { // This might fail in demo environment, so we'll continue fmt.Printf("⚠️ Could not retrieve cloudlet details: %v\n", err) @@ -249,7 +303,11 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 9. Try to Get Cloudlet Manifest (may not be available in demo) fmt.Println("\n9️⃣ Attempting to retrieve cloudlet manifest...") - manifest, err := c.GetCloudletManifest(ctx, cloudletKey, config.Region) + domainCloudletKey = domain.CloudletKey{ + Organization: cloudletKey.Organization, + Name: cloudletKey.Name, + } + manifest, err := c.GetCloudletManifest(ctx, domainCloudletKey, config.Region) if err != nil { fmt.Printf("⚠️ Could not retrieve cloudlet manifest: %v\n", err) } else { @@ -258,8 +316,12 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 10. Try to Get Cloudlet Resource Usage (may not be available in demo) fmt.Println("\n🔟 Attempting to retrieve cloudlet resource usage...") - usage, err := c.GetCloudletResourceUsage(ctx, cloudletKey, config.Region) - if err != nil { + domainCloudletKey = domain.CloudletKey{ + Organization: cloudletKey.Organization, + Name: cloudletKey.Name, + } + usage, err := c.GetCloudletResourceUsage(ctx, domainCloudletKey, config.Region) + if err != nil { fmt.Printf("⚠️ Could not retrieve cloudlet usage: %v\n", err) } else { fmt.Printf("✅ Cloudlet resource usage retrieved\n") @@ -272,21 +334,39 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 11. Delete Application Instance fmt.Println("\n1️⃣1️⃣ Cleaning up application instance...") - if err := c.DeleteAppInstance(ctx, instanceKey, config.Region); err != nil { + domainInstanceKey = domain.AppInstanceKey{ + Organization: instanceKey.Organization, + Name: instanceKey.Name, + CloudletKey: domain.CloudletKey{ + Organization: instanceKey.CloudletKey.Organization, + Name: instanceKey.CloudletKey.Name, + }, + } + if err := c.DeleteAppInstance(ctx, config.Region, domainInstanceKey); err != nil { return fmt.Errorf("failed to delete app instance: %w", err) } fmt.Printf("✅ App instance deleted: %s\n", config.InstanceName) // 12. Delete Application fmt.Println("\n1️⃣2️⃣ Cleaning up application...") - if err := c.DeleteApp(ctx, appKey, config.Region); err != nil { + domainAppKey = domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } + if err := c.DeleteApp(ctx, config.Region, domainAppKey); err != nil { return fmt.Errorf("failed to delete app: %w", err) } fmt.Printf("✅ App deleted: %s/%s v%s\n", config.Organization, config.AppName, config.AppVersion) // 13. Verify Cleanup - fmt.Println("\n1️⃣3️⃣ Verifying cleanup...") - _, err = c.ShowApp(ctx, appKey, config.Region) + fmt.Println("\n1️⃣3️⃣ Verifying cleanup...") + domainAppKey = domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } + _, err = c.ShowApp(ctx, config.Region, domainAppKey) if err != nil && fmt.Sprintf("%v", err) == edgeconnect.ErrResourceNotFound.Error() { fmt.Printf("✅ Cleanup verified - app no longer exists\n") } else if err != nil { @@ -321,7 +401,15 @@ func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKe return edgeconnect.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout) case <-ticker.C: - instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, region) + domainInstanceKey := domain.AppInstanceKey{ + Organization: instanceKey.Organization, + Name: instanceKey.Name, + CloudletKey: domain.CloudletKey{ + Organization: instanceKey.CloudletKey.Organization, + Name: instanceKey.CloudletKey.Name, + }, + } + instance, err := c.ShowAppInstance(timeoutCtx, region, domainInstanceKey) if err != nil { // Log error but continue polling fmt.Printf(" ⚠️ Error checking instance state: %v\n", err) @@ -338,14 +426,12 @@ func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKe state := strings.ToLower(instance.State) if state != "" && state != "creating" && state != "create requested" { if state == "ready" || state == "running" { - fmt.Printf(" ✅ Instance reached ready state: %s\n", instance.State) - return instance, nil - } else if state == "error" || state == "failed" || strings.Contains(state, "error") { - return instance, fmt.Errorf("instance entered error state: %s", instance.State) + return *edgeconnect.ToAPIAppInstance(instance), nil } else if state == "error" || state == "failed" || strings.Contains(state, "error") { + return *edgeconnect.ToAPIAppInstance(instance), fmt.Errorf("instance entered error state: %s", instance.State) } else { // Instance is in some other stable state (not creating) fmt.Printf(" ✅ Instance reached stable state: %s\n", instance.State) - return instance, nil + return *edgeconnect.ToAPIAppInstance(instance), nil } } } diff --git a/sdk/examples/deploy_app.go b/sdk/examples/deploy_app.go index b413886..b870a39 100644 --- a/sdk/examples/deploy_app.go +++ b/sdk/examples/deploy_app.go @@ -12,7 +12,8 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" ) func main() { @@ -76,20 +77,39 @@ func main() { func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client, input *edgeconnect.NewAppInput) error { appKey := input.App.Key region := input.Region + var domainAppKey domain.AppKey fmt.Printf("🚀 Demonstrating EdgeXR SDK with app: %s/%s v%s\n", appKey.Organization, appKey.Name, appKey.Version) // Step 1: Create the application fmt.Println("\n1. Creating application...") - if err := edgeClient.CreateApp(ctx, input); err != nil { + domainApp := &domain.App{ + Key: domain.AppKey{ + Organization: input.App.Key.Organization, + Name: input.App.Key.Name, + Version: input.App.Key.Version, + }, + Deployment: input.App.Deployment, + ImageType: input.App.ImageType, + ImagePath: input.App.ImagePath, + DefaultFlavor: domain.Flavor{Name: input.App.DefaultFlavor.Name}, + ServerlessConfig: input.App.ServerlessConfig, + AllowServerless: input.App.AllowServerless, + } + if err := edgeClient.CreateApp(ctx, input.Region, domainApp); err != nil { return fmt.Errorf("failed to create app: %+v", err) } fmt.Printf("✅ App created: %s/%s v%s\n", appKey.Organization, appKey.Name, appKey.Version) // Step 2: Query the application fmt.Println("\n2. Querying application...") - app, err := edgeClient.ShowApp(ctx, appKey, region) + domainAppKey = domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } + app, err := edgeClient.ShowApp(ctx, region, domainAppKey) if err != nil { return fmt.Errorf("failed to show app: %w", err) } @@ -98,8 +118,8 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client // Step 3: List applications in the organization fmt.Println("\n3. Listing applications...") - filter := edgeconnect.AppKey{Organization: appKey.Organization} - apps, err := edgeClient.ShowApps(ctx, filter, region) + filter := domain.AppKey{Organization: appKey.Organization} + apps, err := edgeClient.ShowApps(ctx, region, filter) if err != nil { return fmt.Errorf("failed to list apps: %w", err) } @@ -107,14 +127,24 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client // Step 4: Clean up - delete the application fmt.Println("\n4. Cleaning up...") - if err := edgeClient.DeleteApp(ctx, appKey, region); err != nil { + domainAppKey = domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } + if err := edgeClient.DeleteApp(ctx, region, domainAppKey); err != nil { return fmt.Errorf("failed to delete app: %w", err) } fmt.Printf("✅ App deleted: %s/%s v%s\n", appKey.Organization, appKey.Name, appKey.Version) // Step 5: Verify deletion fmt.Println("\n5. Verifying deletion...") - _, err = edgeClient.ShowApp(ctx, appKey, region) + domainAppKey = domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } + _, err = edgeClient.ShowApp(ctx, region, domainAppKey) if err != nil { if strings.Contains(fmt.Sprintf("%v", err), edgeconnect.ErrResourceNotFound.Error()) { fmt.Printf("✅ App successfully deleted (not found)\n")