feat(arch) added hexagonal arch impl done with ai

This commit is contained in:
Richard Robert Reitz 2025-10-08 12:55:53 +02:00
parent ce801f30d0
commit ac4001b6f6
42 changed files with 2220 additions and 1459 deletions

BIN
edge-connect-client Executable file

Binary file not shown.

View file

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

View file

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

View file

@ -1,4 +1,4 @@
package cmd
package cli
import (
"testing"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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