diff --git a/.gitignore b/.gitignore index c08c1df..e2dd435 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ edge-connect # Added by goreleaser init: dist/ +# ignore binaries +main +bin/ + ### direnv ### .direnv .envrc diff --git a/Makefile b/Makefile index 496876e..eaa4b42 100644 --- a/Makefile +++ b/Makefile @@ -13,17 +13,17 @@ test: # Run tests with coverage test-coverage: - go test -v -coverprofile=coverage.out ./... - go tool cover -html=coverage.out -o coverage.html + GOTOOLCHAIN=go1.25.1 go test -v -coverprofile=coverage.out ./... + GOTOOLCHAIN=go1.25.1 go tool cover -html=coverage.out -o coverage.html # Build the CLI build: - go build -o bin/edge-connect . + go build -o bin/edge-connect-cli ./cmd/cli # Clean generated files and build artifacts clean: rm -f sdk/client/types_generated.go - rm -f bin/edge-connect + rm -f bin/edge-connect-cli rm -f coverage.out coverage.html # Lint the code diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..019aa48 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "log" + "os" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driving/cli" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/app" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/cloudlet" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/instance" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/organization" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client" +) + +func main() { + // Hexagonal Architecture Wiring + + // 1. Infrastructure Layer: Create the low-level EdgeConnect client + baseURL := getEnvOrDefault("EDGE_CONNECT_BASE_URL", "https://console.mobiledgex.net") + username := os.Getenv("EDGE_CONNECT_USERNAME") + password := os.Getenv("EDGE_CONNECT_PASSWORD") + + // Use a logger for the infrastructure client + logger := log.New(os.Stderr, "[edgeconnect-client] ", log.LstdFlags) + clientOpts := []edgeconnect_client.Option{ + edgeconnect_client.WithLogger(logger), + } + + var infraClient *edgeconnect_client.Client + if username != "" && password != "" { + infraClient = edgeconnect_client.NewClientWithCredentials(baseURL, username, password, clientOpts...) + } else { + infraClient = edgeconnect_client.NewClient(baseURL, clientOpts...) + } + + // 2. Adapter Layer: Create the driven adapter, injecting the infrastructure client. + // This adapter implements the repository interfaces required by the application layer. + edgeConnectAdapter := edgeconnect.NewAdapter(infraClient) + + // 3. Application Layer: Create services, injecting the adapter (which fulfills the repository port). + appService := app.NewService(edgeConnectAdapter) + instanceService := instance.NewService(edgeConnectAdapter) + cloudletService := cloudlet.NewService(edgeConnectAdapter) + organizationService := organization.NewService(edgeConnectAdapter) + + // 4. Driving Adapter (Presentation Layer): Execute the CLI, injecting the application services. + cli.ExecuteWithServices(appService, instanceService, cloudletService, organizationService) +} + +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/cmd/root.go b/cmd/root.go deleted file mode 100644 index 480d8f5..0000000 --- a/cmd/root.go +++ /dev/null @@ -1,72 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var ( - cfgFile string - baseURL string - username string - password string -) - -// rootCmd represents the base command when called without any subcommands -var rootCmd = &cobra.Command{ - Use: "edge-connect", - Short: "A CLI tool for managing Edge Connect applications", - Long: `edge-connect is a command line interface for managing Edge Connect applications -and their instances. It provides functionality to create, show, list, and delete -applications and application instances.`, -} - -// Execute adds all child commands to the root command and sets flags appropriately. -func Execute() { - if err := rootCmd.Execute(); err != nil { - fmt.Println(err) - os.Exit(1) - } -} - -func init() { - cobra.OnInitialize(initConfig) - - rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.edge-connect.yaml)") - rootCmd.PersistentFlags().StringVar(&baseURL, "base-url", "", "base URL for the Edge Connect API") - rootCmd.PersistentFlags().StringVar(&username, "username", "", "username for authentication") - rootCmd.PersistentFlags().StringVar(&password, "password", "", "password for authentication") - - viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")) - viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")) - viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")) -} - -func initConfig() { - viper.AutomaticEnv() - viper.SetEnvPrefix("EDGE_CONNECT") - viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL") - viper.BindEnv("username", "EDGE_CONNECT_USERNAME") - viper.BindEnv("password", "EDGE_CONNECT_PASSWORD") - - if cfgFile != "" { - viper.SetConfigFile(cfgFile) - } else { - home, err := os.UserHomeDir() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - - viper.AddConfigPath(home) - viper.SetConfigType("yaml") - viper.SetConfigName(".edge-connect") - } - - if err := viper.ReadInConfig(); err == nil { - fmt.Println("Using config file:", viper.ConfigFileUsed()) - } -} diff --git a/devbox.json b/devbox.json new file mode 100644 index 0000000..5d7f873 --- /dev/null +++ b/devbox.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json", + "packages": [ + "golangci-lint@latest", + "go@1.25" + ], + "shell": { + "init_hook": [ + "echo 'Welcome to devbox!' > /dev/null" + ], + "scripts": { + "test": [ + "echo \"Error: no test specified\" && exit 1" + ] + } + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 0000000..f3049bd --- /dev/null +++ b/devbox.lock @@ -0,0 +1,105 @@ +{ + "lockfile_version": "1", + "packages": { + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2025-10-07T08:41:47Z", + "resolved": "github:NixOS/nixpkgs/bce5fe2bb998488d8e7e7856315f90496723793c?lastModified=1759826507&narHash=sha256-vwXL9H5zDHEQA0oFpww2one0%2FhkwnPAjc47LRph6d0I%3D" + }, + "go@1.25": { + "last_modified": "2025-08-08T08:05:48Z", + "resolved": "github:NixOS/nixpkgs/a3f3e3f2c983e957af6b07a1db98bafd1f87b7a1#go_1_25", + "source": "devbox-search", + "version": "1.25rc3", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/3h76v26b24dqxhd1i8gzcg8bwzxzmrhl-go-1.25rc3", + "default": true + } + ], + "store_path": "/nix/store/3h76v26b24dqxhd1i8gzcg8bwzxzmrhl-go-1.25rc3" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/1sy3nfyahk3a3pg5x301jx96yxg8sw3y-go-1.25rc3", + "default": true + } + ], + "store_path": "/nix/store/1sy3nfyahk3a3pg5x301jx96yxg8sw3y-go-1.25rc3" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/vh7db8clgyymv47wsddpw908bbf1dikm-go-1.25rc3", + "default": true + } + ], + "store_path": "/nix/store/vh7db8clgyymv47wsddpw908bbf1dikm-go-1.25rc3" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/nbq04hs95dpiwmfnqiky5l4z8azbqj6i-go-1.25rc3", + "default": true + } + ], + "store_path": "/nix/store/nbq04hs95dpiwmfnqiky5l4z8azbqj6i-go-1.25rc3" + } + } + }, + "golangci-lint@latest": { + "last_modified": "2025-09-18T16:33:27Z", + "resolved": "github:NixOS/nixpkgs/f4b140d5b253f5e2a1ff4e5506edbf8267724bde#golangci-lint", + "source": "devbox-search", + "version": "2.4.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/2iiw320mwgw7flh47zbz6l62fakrb3dx-golangci-lint-2.4.0", + "default": true + } + ], + "store_path": "/nix/store/2iiw320mwgw7flh47zbz6l62fakrb3dx-golangci-lint-2.4.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/hwr3wdhqnlcay07xpgv2wm1mx7k5nkhf-golangci-lint-2.4.0", + "default": true + } + ], + "store_path": "/nix/store/hwr3wdhqnlcay07xpgv2wm1mx7k5nkhf-golangci-lint-2.4.0" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/skcc363l41rm6hjyrhzlfbk3rrwci2lb-golangci-lint-2.4.0", + "default": true + } + ], + "store_path": "/nix/store/skcc363l41rm6hjyrhzlfbk3rrwci2lb-golangci-lint-2.4.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/dlz6z4dih7rd6q9dnigvz49npfmv8m52-golangci-lint-2.4.0", + "default": true + } + ], + "store_path": "/nix/store/dlz6z4dih7rd6q9dnigvz49npfmv8m52-golangci-lint-2.4.0" + } + } + } + } +} diff --git a/flake.nix b/flake.nix index 2536eb7..f32815c 100644 --- a/flake.nix +++ b/flake.nix @@ -5,7 +5,7 @@ outputs = inputs: let - goVersion = 25; # Change this to update the whole stack + goVersion = "1_25_1"; # Change this to update the whole stack supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; forEachSupportedSystem = f: inputs.nixpkgs.lib.genAttrs supportedSystems (system: f { @@ -17,7 +17,7 @@ in { overlays.default = final: prev: { - go = final."go_1_${toString goVersion}"; + go = final."go_${goVersion}"; }; devShells = forEachSupportedSystem ({ pkgs }: { diff --git a/hexagonal-architecture-proposal.md b/hexagonal-architecture-proposal.md new file mode 100644 index 0000000..84e0dab --- /dev/null +++ b/hexagonal-architecture-proposal.md @@ -0,0 +1,76 @@ +# Proposal: Refactor to Hexagonal Architecture + +This document proposes a refactoring of the `edge-connect-client` project to a Hexagonal Architecture (also known as Ports and Adapters). This will improve the project's maintainability, testability, and flexibility. + +## Current Architecture + +The current project structure is a mix of concerns. The `cmd` package contains both CLI handling and business logic, the `sdk` package is a client for the EdgeXR API, and the `internal` package contains some business logic and configuration handling. This makes it difficult to test the business logic in isolation and to adapt the application to different use cases. + +## Proposed Hexagonal Architecture + +The hexagonal architecture separates the application's core business logic from the outside world. The core communicates with the outside world through ports (interfaces), which are implemented by adapters. + +Here is the proposed directory structure: + +``` +. +├── cmd/ +│ └── main.go +├── internal/ +│ ├── core/ +│ │ ├── domain/ +│ │ │ ├── app.go +│ │ │ └── instance.go +│ │ ├── ports/ +│ │ │ ├── driven/ +│ │ │ │ ├── app_repository.go +│ │ │ │ └── instance_repository.go +│ │ │ └── driving/ +│ │ │ ├── app_service.go +│ │ │ └── instance_service.go +│ │ └── services/ +│ │ ├── app_service.go +│ │ └── instance_service.go +│ └── adapters/ +│ ├── cli/ +│ │ ├── app.go +│ │ └── instance.go +│ └── edgeconnect/ +│ ├── app.go +│ └── instance.go +├── go.mod +└── go.sum +``` + +### Core + +* `internal/core/domain`: Contains the core domain objects (e.g., `App`, `AppInstance`). These are plain Go structs with no external dependencies. +* `internal/core/ports`: Defines the interfaces for communication with the outside world. + * `driving`: Interfaces for the services offered by the application (e.g., `AppService`, `InstanceService`). + * `driven`: Interfaces for the services the application needs (e.g., `AppRepository`, `InstanceRepository`). +* `internal/core/services`: Implements the `driving` port interfaces. This is where the core business logic resides. + +### Adapters + +* `internal/adapters/driving/cli`: The CLI adapter. It implements the user interface and calls the `driving` ports of the core. +* `internal/adapters/driven/edgeconnect`: The EdgeXR API adapter. It implements the `driven` port interfaces and communicates with the EdgeXR API. + +### `cmd` + +* `cmd/cli/main.go`: The main entry point of the CLI 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/driving/cli` and adapt it to call the core services. + * Move the existing `sdk` code to `internal/adapters/driven/edgeconnect` and adapt it to implement the repository interfaces. +5. **Wire everything together:** Update `cmd/cli/main.go` to create the adapters and inject them into the core services. + +## Benefits + +* **Improved Testability:** The core business logic can be tested in isolation, without the need for the CLI framework or the EdgeXR API. +* **Increased Flexibility:** The application can be easily adapted to different use cases by creating new adapters. For example, we could add a REST API by creating a new adapter. +* **Better Separation of Concerns:** The hexagonal architecture enforces a clear separation between the business logic and the infrastructure, making the code easier to understand and maintain. diff --git a/internal/adapters/driven/edgeconnect/adapter.go b/internal/adapters/driven/edgeconnect/adapter.go new file mode 100644 index 0000000..274c8a2 --- /dev/null +++ b/internal/adapters/driven/edgeconnect/adapter.go @@ -0,0 +1,705 @@ +package edgeconnect + +import ( + "context" + "fmt" + "net/http" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven" +) + +// Adapter implements the driven ports for the EdgeConnect API. +// It acts as a bridge between the application's core logic and the +// underlying infrastructure client, translating domain requests into +// infrastructure calls. +type Adapter struct { + client *edgeconnect_client.Client +} + +// NewAdapter creates a new EdgeConnect adapter. +// It requires a configured infrastructure client to communicate with the API. +func NewAdapter(client *edgeconnect_client.Client) *Adapter { + return &Adapter{client: client} +} + +// Ensure the adapter implements all required repository interfaces. +var _ driven.AppRepository = (*Adapter)(nil) +var _ driven.AppInstanceRepository = (*Adapter)(nil) +var _ driven.CloudletRepository = (*Adapter)(nil) +var _ driven.OrganizationRepository = (*Adapter)(nil) + +// OrganizationRepository implementation + +// CreateOrganization creates a new organization. +func (a *Adapter) CreateOrganization(ctx context.Context, org *domain.Organization) error { + apiPath := "/api/v1/auth/org/create" + _, err := a.client.Call(ctx, http.MethodPost, apiPath, org, nil) + if err != nil { + // TODO: Improve error handling to return domain-specific errors + return fmt.Errorf("failed to create organization %s: %w", org.Name, err) + } + a.client.Logf("Successfully created organization: %s", org.Name) + return nil +} + +// ShowOrganization retrieves a single organization by name. +func (a *Adapter) ShowOrganization(ctx context.Context, name string) (*domain.Organization, error) { + apiPath := "/api/v1/auth/org/show" + reqBody := map[string]string{"name": name} + var orgs []domain.Organization + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, reqBody, &orgs) + if err != nil { + // TODO: Improve error handling, check for 404 and return domain.ErrResourceNotFound + return nil, fmt.Errorf("failed to show organization %s: %w", name, err) + } + + if len(orgs) == 0 { + return nil, fmt.Errorf("organization '%s' not found", name) + } + + if len(orgs) > 1 { + a.client.Logf("warning: ShowOrganization for '%s' returned %d results, expected 1", name, len(orgs)) + } + + return &orgs[0], nil +} + +// UpdateOrganization updates an existing organization. +func (a *Adapter) UpdateOrganization(ctx context.Context, org *domain.Organization) error { + apiPath := "/api/v1/auth/org/update" + _, err := a.client.Call(ctx, http.MethodPost, apiPath, org, nil) + if err != nil { + return fmt.Errorf("failed to update organization %s: %w", org.Name, err) + } + a.client.Logf("Successfully updated organization: %s", org.Name) + return nil +} + +// DeleteOrganization deletes an organization by name. +func (a *Adapter) DeleteOrganization(ctx context.Context, name string) error { + apiPath := "/api/v1/auth/org/delete" + reqBody := map[string]string{"name": name} + + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, reqBody, nil) + if err != nil { + // A 404 status is acceptable, means it's already deleted. + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + a.client.Logf("Organization %s not found for deletion, considered successful.", name) + return nil + } + return fmt.Errorf("failed to delete organization %s: %w", name, err) + } + // The Call method now handles the response body closure if result is not nil. + // If result is nil, we must close it. + if resp != nil && resp.Body != nil { + defer func() { + _ = resp.Body.Close() + }() + } + + a.client.Logf("Successfully deleted organization: %s", name) + return nil +} + +// AppRepository implementation + +// CreateApp creates a new application. +func (a *Adapter) CreateApp(ctx context.Context, region string, app *domain.App) error { + apiPath := "/api/v1/auth/ctrl/CreateApp" + apiApp := toAPIApp(app) + input := &edgeconnect_client.NewAppInput{ + Region: region, + App: *apiApp, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil) + if err != nil { + return fmt.Errorf("CreateApp failed: %w", err) + } + a.client.Logf("CreateApp: %s/%s version %s created successfully", + input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version) + return nil +} + +// ShowApp retrieves a single application. +func (a *Adapter) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) { + apiPath := "/api/v1/auth/ctrl/ShowApp" + apiAppKey := toAPIAppKey(appKey) + filter := edgeconnect_client.AppFilter{ + App: edgeconnect_client.App{Key: *apiAppKey}, + Region: region, + } + + var apps []edgeconnect_client.App + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, "application not found") + } + return nil, fmt.Errorf("ShowApp failed: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if err := a.client.ParseStreamingResponse(resp, &apps); err != nil { + return nil, fmt.Errorf("ShowApp failed to parse response: %w", err) + } + + if len(apps) == 0 { + return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, "application not found") + } + + domainApp := toDomainApp(&apps[0]) + return &domainApp, nil +} + +// ShowApps retrieves all applications matching the filter. +func (a *Adapter) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) { + apiPath := "/api/v1/auth/ctrl/ShowApp" + apiAppKey := toAPIAppKey(appKey) + filter := edgeconnect_client.AppFilter{ + App: edgeconnect_client.App{Key: *apiAppKey}, + Region: region, + } + + var apiApps []edgeconnect_client.App + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return []domain.App{}, nil // Return empty slice for not found + } + return nil, fmt.Errorf("ShowApps failed: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if err := a.client.ParseStreamingResponse(resp, &apiApps); err != nil { + return nil, fmt.Errorf("ShowApps failed to parse response: %w", err) + } + + a.client.Logf("ShowApps: found %d apps matching criteria", len(apiApps)) + domainApps := make([]domain.App, len(apiApps)) + for i := range apiApps { + domainApps[i] = toDomainApp(&apiApps[i]) + } + return domainApps, nil +} + +// UpdateApp updates an existing application. +func (a *Adapter) UpdateApp(ctx context.Context, region string, app *domain.App) error { + apiPath := "/api/v1/auth/ctrl/UpdateApp" + apiApp := toAPIApp(app) + input := &edgeconnect_client.UpdateAppInput{ + Region: region, + App: *apiApp, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil) + if err != nil { + return fmt.Errorf("UpdateApp failed: %w", err) + } + a.client.Logf("UpdateApp: %s/%s version %s updated successfully", + input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version) + return nil +} + +// DeleteApp deletes an application. +func (a *Adapter) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error { + apiPath := "/api/v1/auth/ctrl/DeleteApp" + apiAppKey := toAPIAppKey(appKey) + filter := edgeconnect_client.AppFilter{ + App: edgeconnect_client.App{Key: *apiAppKey}, + Region: region, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + a.client.Logf("App %v not found for deletion, considered successful.", appKey) + return nil + } + return fmt.Errorf("DeleteApp failed: %w", err) + } + a.client.Logf("DeleteApp: %s/%s version %s deleted successfully", + appKey.Organization, appKey.Name, appKey.Version) + return nil +} + +// AppInstanceRepository implementation + +// CreateAppInstance creates a new application instance. +func (a *Adapter) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + apiPath := "/api/v1/auth/ctrl/CreateAppInst" + apiAppInst := toAPIAppInstance(appInst) + input := &edgeconnect_client.NewAppInstanceInput{ + Region: region, + AppInst: *apiAppInst, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil) + if err != nil { + return fmt.Errorf("CreateAppInstance failed: %w", err) + } + a.client.Logf("CreateAppInstance: %s/%s created successfully", + input.AppInst.Key.Organization, input.AppInst.Key.Name) + return nil +} + +// ShowAppInstance retrieves a single application instance. +func (a *Adapter) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) { + apiPath := "/api/v1/auth/ctrl/ShowAppInst" + apiAppInstKey := toAPIAppInstanceKey(appInstKey) + filter := edgeconnect_client.AppInstanceFilter{ + AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey}, + Region: region, + } + + var appInstances []edgeconnect_client.AppInstance + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, "app instance not found") + } + return nil, fmt.Errorf("ShowAppInstance failed: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if err := a.client.ParseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + return nil, fmt.Errorf("ShowAppInstance failed to parse response: %w", err) + } + + if len(appInstances) == 0 { + return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, "app instance not found") + } + + domainAppInst := toDomainAppInstance(&appInstances[0]) + return &domainAppInst, nil +} + +// ShowAppInstances retrieves all application instances matching the filter. +func (a *Adapter) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) { + apiPath := "/api/v1/auth/ctrl/ShowAppInst" + apiAppInstKey := toAPIAppInstanceKey(appInstKey) + filter := edgeconnect_client.AppInstanceFilter{ + AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey}, + Region: region, + } + + var appInstances []edgeconnect_client.AppInstance + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return []domain.AppInstance{}, nil + } + return nil, fmt.Errorf("ShowAppInstances failed: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if err := a.client.ParseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err) + } + + a.client.Logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances)) + domainAppInsts := make([]domain.AppInstance, len(appInstances)) + for i := range appInstances { + domainAppInsts[i] = toDomainAppInstance(&appInstances[i]) + } + return domainAppInsts, nil +} + +// UpdateAppInstance updates an existing application instance. +func (a *Adapter) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + apiPath := "/api/v1/auth/ctrl/UpdateAppInst" + apiAppInst := toAPIAppInstance(appInst) + input := &edgeconnect_client.UpdateAppInstanceInput{ + Region: region, + AppInst: *apiAppInst, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil) + if err != nil { + return fmt.Errorf("UpdateAppInstance failed: %w", err) + } + a.client.Logf("UpdateAppInstance: %s/%s updated successfully", + input.AppInst.Key.Organization, input.AppInst.Key.Name) + return nil +} + +// RefreshAppInstance refreshes an application instance. +func (a *Adapter) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + apiPath := "/api/v1/auth/ctrl/RefreshAppInst" + apiAppInstKey := toAPIAppInstanceKey(appInstKey) + filter := edgeconnect_client.AppInstanceFilter{ + AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey}, + Region: region, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + return fmt.Errorf("RefreshAppInstance failed: %w", err) + } + a.client.Logf("RefreshAppInstance: %s/%s refreshed successfully", + appInstKey.Organization, appInstKey.Name) + return nil +} + +// DeleteAppInstance deletes an application instance. +func (a *Adapter) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + apiPath := "/api/v1/auth/ctrl/DeleteAppInst" + apiAppInstKey := toAPIAppInstanceKey(appInstKey) + filter := edgeconnect_client.AppInstanceFilter{ + AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey}, + Region: region, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + a.client.Logf("AppInstance %v not found for deletion, considered successful.", appInstKey) + return nil + } + return fmt.Errorf("DeleteAppInstance failed: %w", err) + } + a.client.Logf("DeleteAppInstance: %s/%s deleted successfully", + appInstKey.Organization, appInstKey.Name) + return nil +} + +// CloudletRepository implementation + +// CreateCloudlet creates a new cloudlet. +func (a *Adapter) CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error { + apiPath := "/api/v1/auth/ctrl/CreateCloudlet" + apiCloudlet := toAPICloudlet(cloudlet) + input := &edgeconnect_client.NewCloudletInput{ + Region: region, + Cloudlet: *apiCloudlet, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil) + if err != nil { + return fmt.Errorf("CreateCloudlet failed: %w", err) + } + a.client.Logf("CreateCloudlet: %s/%s created successfully", + input.Cloudlet.Key.Organization, input.Cloudlet.Key.Name) + return nil +} + +// ShowCloudlet retrieves a single cloudlet. +func (a *Adapter) ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) { + apiPath := "/api/v1/auth/ctrl/ShowCloudlet" + apiCloudletKey := toAPICloudletKey(cloudletKey) + filter := edgeconnect_client.CloudletFilter{ + Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey}, + Region: region, + } + + var cloudlets []edgeconnect_client.Cloudlet + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, "cloudlet not found") + } + return nil, fmt.Errorf("ShowCloudlet failed: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if err := a.client.ParseStreamingCloudletResponse(resp, &cloudlets); err != nil { + return nil, fmt.Errorf("ShowCloudlet failed to parse response: %w", err) + } + + if len(cloudlets) == 0 { + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, "cloudlet not found") + } + + domainCloudlet := toDomainCloudlet(&cloudlets[0]) + return &domainCloudlet, nil +} + +// ShowCloudlets retrieves all cloudlets matching the filter. +func (a *Adapter) ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) { + apiPath := "/api/v1/auth/ctrl/ShowCloudlet" + apiCloudletKey := toAPICloudletKey(cloudletKey) + filter := edgeconnect_client.CloudletFilter{ + Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey}, + Region: region, + } + + var cloudlets []edgeconnect_client.Cloudlet + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return []domain.Cloudlet{}, nil + } + return nil, fmt.Errorf("ShowCloudlets failed: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if err := a.client.ParseStreamingCloudletResponse(resp, &cloudlets); err != nil { + return nil, fmt.Errorf("ShowCloudlets failed to parse response: %w", err) + } + + a.client.Logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets)) + domainCloudlets := make([]domain.Cloudlet, len(cloudlets)) + for i := range cloudlets { + domainCloudlets[i] = toDomainCloudlet(&cloudlets[i]) + } + return domainCloudlets, nil +} + +// DeleteCloudlet deletes a cloudlet. +func (a *Adapter) DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error { + apiPath := "/api/v1/auth/ctrl/DeleteCloudlet" + apiCloudletKey := toAPICloudletKey(cloudletKey) + filter := edgeconnect_client.CloudletFilter{ + Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey}, + Region: region, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + a.client.Logf("Cloudlet %v not found for deletion, considered successful.", cloudletKey) + return nil + } + return fmt.Errorf("DeleteCloudlet failed: %w", err) + } + a.client.Logf("DeleteCloudlet: %s/%s deleted successfully", + cloudletKey.Organization, cloudletKey.Name) + return nil +} + +// GetCloudletManifest retrieves the deployment manifest for a cloudlet. +func (a *Adapter) GetCloudletManifest(ctx context.Context, cloudletKey domain.CloudletKey, region string) (*edgeconnect_client.CloudletManifest, error) { + apiPath := "/api/v1/auth/ctrl/GetCloudletManifest" + apiCloudletKey := toAPICloudletKey(cloudletKey) + filter := edgeconnect_client.CloudletFilter{ + Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey}, + Region: region, + } + + var manifest edgeconnect_client.CloudletManifest + _, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, &manifest) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "GetCloudletManifest", cloudletKey, region, "cloudlet manifest not found") + } + return nil, fmt.Errorf("GetCloudletManifest failed: %w", err) + } + a.client.Logf("GetCloudletManifest: retrieved manifest for %s/%s", + cloudletKey.Organization, cloudletKey.Name) + return &manifest, nil +} + +// GetCloudletResourceUsage retrieves resource usage for a cloudlet. +func (a *Adapter) GetCloudletResourceUsage(ctx context.Context, cloudletKey domain.CloudletKey, region string) (*edgeconnect_client.CloudletResourceUsage, error) { + apiPath := "/api/v1/auth/ctrl/GetCloudletResourceUsage" + apiCloudletKey := toAPICloudletKey(cloudletKey) + filter := edgeconnect_client.CloudletFilter{ + Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey}, + Region: region, + } + + var usage edgeconnect_client.CloudletResourceUsage + _, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, &usage) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "GetCloudletResourceUsage", cloudletKey, region, "cloudlet resource usage not found") + } + return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err) + } + a.client.Logf("GetCloudletResourceUsage: retrieved usage for %s/%s", + cloudletKey.Organization, cloudletKey.Name) + return &usage, nil +} + +// Data mapping functions (domain <-> API) + +func toAPIApp(app *domain.App) *edgeconnect_client.App { + return &edgeconnect_client.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 *edgeconnect_client.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) *edgeconnect_client.AppKey { + return &edgeconnect_client.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } +} + +func toDomainAppKey(appKey edgeconnect_client.AppKey) domain.AppKey { + return domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } +} + +func toAPIFlavor(flavor domain.Flavor) edgeconnect_client.Flavor { + return edgeconnect_client.Flavor{Name: flavor.Name} +} + +func toDomainFlavor(flavor edgeconnect_client.Flavor) domain.Flavor { + return domain.Flavor{Name: flavor.Name} +} + +func toAPISecurityRules(rules []domain.SecurityRule) []edgeconnect_client.SecurityRule { + apiRules := make([]edgeconnect_client.SecurityRule, len(rules)) + for i, r := range rules { + apiRules[i] = edgeconnect_client.SecurityRule{ + PortRangeMax: r.PortRangeMax, + PortRangeMin: r.PortRangeMin, + Protocol: r.Protocol, + RemoteCIDR: r.RemoteCIDR, + } + } + return apiRules +} + +func toDomainSecurityRules(rules []edgeconnect_client.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 +} + +func toAPIAppInstance(appInst *domain.AppInstance) *edgeconnect_client.AppInstance { + return &edgeconnect_client.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 *edgeconnect_client.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) *edgeconnect_client.AppInstanceKey { + return &edgeconnect_client.AppInstanceKey{ + Organization: key.Organization, + Name: key.Name, + CloudletKey: toAPICloudletKey(key.CloudletKey), + } +} + +func toDomainAppInstanceKey(key edgeconnect_client.AppInstanceKey) domain.AppInstanceKey { + return domain.AppInstanceKey{ + Organization: key.Organization, + Name: key.Name, + CloudletKey: toDomainCloudletKey(key.CloudletKey), + } +} + +func toAPICloudletKey(key domain.CloudletKey) edgeconnect_client.CloudletKey { + return edgeconnect_client.CloudletKey{ + Organization: key.Organization, + Name: key.Name, + } +} + +func toDomainCloudletKey(key edgeconnect_client.CloudletKey) domain.CloudletKey { + return domain.CloudletKey{ + Organization: key.Organization, + Name: key.Name, + } +} + +func toAPICloudlet(cloudlet *domain.Cloudlet) *edgeconnect_client.Cloudlet { + return &edgeconnect_client.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 *edgeconnect_client.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) edgeconnect_client.Location { + return edgeconnect_client.Location{ + Latitude: location.Latitude, + Longitude: location.Longitude, + } +} + +func toDomainLocation(location edgeconnect_client.Location) domain.Location { + return domain.Location{ + Latitude: location.Latitude, + Longitude: location.Longitude, + } +} diff --git a/cmd/app.go b/internal/adapters/driving/cli/app.go similarity index 54% rename from cmd/app.go rename to internal/adapters/driving/cli/app.go index a9f187f..28855c5 100644 --- a/cmd/app.go +++ b/internal/adapters/driving/cli/app.go @@ -1,18 +1,46 @@ -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/domain" "github.com/spf13/cobra" - "github.com/spf13/viper" ) +// validateBaseURL checks if the provided string is a valid base URL. +// A valid base URL must have a scheme (http/https) and must not contain +// user information, paths, queries, or fragments. +func validateBaseURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL format: %w", err) + } + + if u.Scheme == "" { + return fmt.Errorf("URL must have a scheme (e.g., http, https)") + } + + if u.User != nil { + return fmt.Errorf("URL should not contain user information") + } + + if u.Path != "" && u.Path != "/" { + return fmt.Errorf("URL should not contain a path") + } + + if u.RawQuery != "" { + return fmt.Errorf("URL should not contain a query string") + } + + if u.Fragment != "" { + return fmt.Errorf("URL should not contain a fragment") + } + + return nil +} var ( organization string appName string @@ -20,58 +48,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 +58,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 := services.AppService.CreateApp(context.Background(), region, app) if err != nil { fmt.Printf("Error creating app: %v\n", err) os.Exit(1) @@ -107,15 +79,24 @@ 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 := services.AppService.ShowApp(context.Background(), region, appKey) if err != nil { + // Handle domain-specific errors with appropriate user feedback + if domain.IsNotFoundError(err) { + fmt.Printf("Application %s/%s (version %s) not found in region %s\n", + appKey.Organization, appKey.Name, appKey.Version, region) + os.Exit(1) + } + if domain.IsValidationError(err) { + fmt.Printf("Validation error: %v\n", err) + os.Exit(1) + } fmt.Printf("Error showing app: %v\n", err) os.Exit(1) } @@ -127,14 +108,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 := services.AppService.ShowApps(context.Background(), region, appKey) if err != nil { fmt.Printf("Error listing apps: %v\n", err) os.Exit(1) @@ -150,14 +130,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 := services.AppService.DeleteApp(context.Background(), region, appKey) if err != nil { fmt.Printf("Error deleting app: %v\n", err) os.Exit(1) @@ -177,12 +156,18 @@ func init() { cmd.Flags().StringVarP(&appName, "name", "n", "", "application name") cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version") cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)") - cmd.MarkFlagRequired("org") - cmd.MarkFlagRequired("region") + if err := cmd.MarkFlagRequired("org"); err != nil { + panic(fmt.Sprintf("Failed to mark 'org' flag as required: %v", err)) + } + if err := cmd.MarkFlagRequired("region"); err != nil { + panic(fmt.Sprintf("Failed to mark 'region' flag as required: %v", err)) + } } // Add required name flag for specific commands for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} { - cmd.MarkFlagRequired("name") + if err := cmd.MarkFlagRequired("name"); err != nil { + panic(fmt.Sprintf("Failed to mark 'name' flag as required: %v", err)) + } } -} +} \ No newline at end of file diff --git a/cmd/app_test.go b/internal/adapters/driving/cli/app_test.go similarity index 99% rename from cmd/app_test.go rename to internal/adapters/driving/cli/app_test.go index 4b856ea..94df460 100644 --- a/cmd/app_test.go +++ b/internal/adapters/driving/cli/app_test.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "testing" diff --git a/cmd/apply.go b/internal/adapters/driving/cli/apply.go similarity index 68% rename from cmd/apply.go rename to internal/adapters/driving/cli/apply.go index 41e94e9..3cd9c27 100644 --- a/cmd/apply.go +++ b/internal/adapters/driving/cli/apply.go @@ -1,17 +1,21 @@ // 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/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/apply" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client" "github.com/spf13/cobra" ) @@ -30,7 +34,9 @@ the necessary changes to deploy your applications across multiple cloudlets.`, Run: func(cmd *cobra.Command, args []string) { if configFile == "" { fmt.Fprintf(os.Stderr, "Error: configuration file is required\n") - cmd.Usage() + if err := cmd.Usage(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to display usage: %v\n", err) + } os.Exit(1) } @@ -68,10 +74,35 @@ 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("EDGE_CONNECT_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live") + token := getEnvOrDefault("EDGE_CONNECT_TOKEN", "") + username := getEnvOrDefault("EDGE_CONNECT_USERNAME", "") + password := getEnvOrDefault("EDGE_CONNECT_PASSWORD", "") - // Step 4: Create deployment planner - planner := apply.NewPlanner(client) + var client *edgeconnect_client.Client + + if token != "" { + fmt.Println("🔐 Using Bearer token authentication") + client = edgeconnect_client.NewClient(baseURL, + edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)), + edgeconnect_client.WithLogger(log.Default()), + ) + } else if username != "" && password != "" { + fmt.Println("🔐 Using username/password authentication") + client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password, + edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect_client.WithLogger(log.Default()), + ) + } else { + log.Fatal("Authentication required: Set either EDGE_CONNECT_TOKEN or both EDGE_CONNECT_USERNAME and EDGE_CONNECT_PASSWORD") + } + + // Step 4: Create driven adapter + adapter := edgeconnect.NewAdapter(client) + + // Step 5: Create deployment planner + planner := apply.NewPlanner(adapter, adapter) // Step 5: Generate deployment plan fmt.Println("🔍 Analyzing current state and generating deployment plan...") @@ -121,7 +152,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(adapter, adapter, apply.WithLogger(log.Default())) deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent) if err != nil { return fmt.Errorf("deployment failed: %w", err) @@ -156,7 +187,10 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { func confirmDeployment() bool { fmt.Print("Do you want to proceed? (yes/no): ") var response string - fmt.Scanln(&response) + if _, err := fmt.Scanln(&response); err != nil { + fmt.Fprintf(os.Stderr, "Failed to read input: %v\n", err) + return false + } switch response { case "yes", "y", "YES", "Y": @@ -166,6 +200,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) @@ -173,5 +214,7 @@ func init() { applyCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without applying them") applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan") - applyCmd.MarkFlagRequired("file") + if err := applyCmd.MarkFlagRequired("file"); err != nil { + panic(fmt.Sprintf("Failed to mark 'file' flag as required: %v", err)) + } } diff --git a/cmd/instance.go b/internal/adapters/driving/cli/instance.go similarity index 66% rename from cmd/instance.go rename to internal/adapters/driving/cli/instance.go index de22062..99f4b26 100644 --- a/cmd/instance.go +++ b/internal/adapters/driving/cli/instance.go @@ -1,11 +1,11 @@ -package cmd +package cli import ( "context" "fmt" "os" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/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 := services.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 := services.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 := services.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 := services.InstanceService.DeleteAppInstance(context.Background(), region, instanceKey) if err != nil { fmt.Printf("Error deleting app instance: %v\n", err) os.Exit(1) @@ -143,17 +136,21 @@ func init() { cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)") cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)") - cmd.MarkFlagRequired("org") - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("cloudlet") - cmd.MarkFlagRequired("cloudlet-org") - cmd.MarkFlagRequired("region") + for _, flag := range []string{"org", "name", "cloudlet", "cloudlet-org", "region"} { + if err := cmd.MarkFlagRequired(flag); err != nil { + panic(fmt.Sprintf("Failed to mark '%s' flag as required: %v", flag, err)) + } + } } // Add additional flags for create command createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)") createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version") createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)") - createInstanceCmd.MarkFlagRequired("app") - createInstanceCmd.MarkFlagRequired("flavor") -} + if err := createInstanceCmd.MarkFlagRequired("app"); err != nil { + panic(fmt.Sprintf("Failed to mark 'app' flag as required: %v", err)) + } + if err := createInstanceCmd.MarkFlagRequired("flavor"); err != nil { + panic(fmt.Sprintf("Failed to mark 'flavor' flag as required: %v", err)) + } +} \ No newline at end of file diff --git a/internal/adapters/driving/cli/organization.go b/internal/adapters/driving/cli/organization.go new file mode 100644 index 0000000..b0efe9f --- /dev/null +++ b/internal/adapters/driving/cli/organization.go @@ -0,0 +1,112 @@ +package cli + +import ( + "context" + "fmt" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(organizationCmd) + organizationCmd.AddCommand(createOrganizationCmd, showOrganizationCmd, updateOrganizationCmd, deleteOrganizationCmd) + + // Flags for create/update + createOrganizationCmd.Flags().StringVar(&orgAddress, "address", "", "Address of the organization") + createOrganizationCmd.Flags().StringVar(&orgPhone, "phone", "", "Phone number of the organization") + + updateOrganizationCmd.Flags().StringVar(&orgAddress, "address", "", "New address for the organization") + updateOrganizationCmd.Flags().StringVar(&orgPhone, "phone", "", "New phone number for the organization") +} + +var ( + orgAddress string + orgPhone string +) + +// organizationCmd represents the parent command for all organization-related actions. +var organizationCmd = &cobra.Command{ + Use: "organization", + Short: "Manage organizations", + Long: `Create, show, update, and delete organizations in Edge Connect.`, + Aliases: []string{"org"}, +} + +var createOrganizationCmd = &cobra.Command{ + Use: "create [name]", + Short: "Create a new organization", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + org := &domain.Organization{ + Name: args[0], + Address: orgAddress, + Phone: orgPhone, + } + err := services.OrganizationService.Create(context.Background(), org) + if err != nil { + return fmt.Errorf("error creating organization: %w", err) + } + fmt.Printf("Organization '%s' created successfully.\n", args[0]) + return nil + }, +} + +var showOrganizationCmd = &cobra.Command{ + Use: "show [name]", + Short: "Show details of an organization", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + org, err := services.OrganizationService.Get(context.Background(), args[0]) + if err != nil { + return fmt.Errorf("error showing organization: %w", err) + } + fmt.Printf("Organization Details:\n") + fmt.Printf(" Name: %s\n", org.Name) + fmt.Printf(" Address: %s\n", org.Address) + fmt.Printf(" Phone: %s\n", org.Phone) + return nil + }, +} + +var updateOrganizationCmd = &cobra.Command{ + Use: "update [name]", + Short: "Update an organization", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // First, get the current organization to update + org, err := services.OrganizationService.Get(context.Background(), args[0]) + if err != nil { + return fmt.Errorf("could not retrieve organization to update: %w", err) + } + + // Update fields if flags were provided + if cmd.Flags().Changed("address") { + org.Address = orgAddress + } + if cmd.Flags().Changed("phone") { + org.Phone = orgPhone + } + + err = services.OrganizationService.Update(context.Background(), org) + if err != nil { + return fmt.Errorf("error updating organization: %w", err) + } + fmt.Printf("Organization '%s' updated successfully.\n", args[0]) + return nil + }, +} + +var deleteOrganizationCmd = &cobra.Command{ + Use: "delete [name]", + Short: "Delete an organization", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + err := services.OrganizationService.Delete(context.Background(), args[0]) + if err != nil { + return fmt.Errorf("error deleting organization: %w", err) + } + fmt.Printf("Organization '%s' deleted successfully.\n", args[0]) + return nil + }, +} diff --git a/internal/adapters/driving/cli/root.go b/internal/adapters/driving/cli/root.go new file mode 100644 index 0000000..d70d4fe --- /dev/null +++ b/internal/adapters/driving/cli/root.go @@ -0,0 +1,111 @@ +package cli + +import ( + "fmt" + "os" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + cfgFile string + baseURL string + username string + password string + + // Services injected via constructor - no global state + services *ServiceContainer +) + +// ServiceContainer holds injected services (simple struct - no complex container) +type ServiceContainer struct { + AppService driving.AppService + InstanceService driving.AppInstanceService + CloudletService driving.CloudletService + OrganizationService driving.OrganizationService +} + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "edge-connect", + Short: "A CLI tool for managing Edge Connect applications", + Long: `edge-connect is a command line interface for managing Edge Connect applications +and their instances. It provides functionality to create, show, list, and delete +applications and application instances.`, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +// ExecuteWithServices executes CLI with dependency-injected services (simple parameter passing) +func ExecuteWithServices(appSvc driving.AppService, instanceSvc driving.AppInstanceService, cloudletSvc driving.CloudletService, orgSvc driving.OrganizationService) { + // Simple dependency injection - just store services in container + services = &ServiceContainer{ + AppService: appSvc, + InstanceService: instanceSvc, + CloudletService: cloudletSvc, + OrganizationService: orgSvc, + } + + Execute() +} + +func init() { + cobra.OnInitialize(initConfig) + + rootCmd.AddCommand(organizationCmd) + + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.edge-connect.yaml)") + rootCmd.PersistentFlags().StringVar(&baseURL, "base-url", "", "base URL for the Edge Connect API") + rootCmd.PersistentFlags().StringVar(&username, "username", "", "username for authentication") + rootCmd.PersistentFlags().StringVar(&password, "password", "", "password for authentication") + + if err := viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")); err != nil { + panic(fmt.Sprintf("Failed to bind base-url flag: %v", err)) + } + if err := viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")); err != nil { + panic(fmt.Sprintf("Failed to bind username flag: %v", err)) + } + if err := viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")); err != nil { + panic(fmt.Sprintf("Failed to bind password flag: %v", err)) + } +} + +func initConfig() { + viper.AutomaticEnv() + viper.SetEnvPrefix("EDGE_CONNECT") + if err := viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL"); err != nil { + panic(fmt.Sprintf("Failed to bind base_url environment variable: %v", err)) + } + if err := viper.BindEnv("username", "EDGE_CONNECT_USERNAME"); err != nil { + panic(fmt.Sprintf("Failed to bind username environment variable: %v", err)) + } + if err := viper.BindEnv("password", "EDGE_CONNECT_PASSWORD"); err != nil { + panic(fmt.Sprintf("Failed to bind password environment variable: %v", err)) + } + + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + home, err := os.UserHomeDir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + viper.AddConfigPath(home) + viper.SetConfigType("yaml") + viper.SetConfigName(".edge-connect") + } + + if err := viper.ReadInConfig(); err == nil { + fmt.Println("Using config file:", viper.ConfigFileUsed()) + } +} \ No newline at end of file diff --git a/internal/application/app/service.go b/internal/application/app/service.go new file mode 100644 index 0000000..62aa8ee --- /dev/null +++ b/internal/application/app/service.go @@ -0,0 +1,157 @@ +package app + +import ( + "context" + "strings" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving" +) + +type service struct { + appRepo driven.AppRepository +} + +func NewService(appRepo driven.AppRepository) driving.AppService { + return &service{appRepo: appRepo} +} + +func (s *service) CreateApp(ctx context.Context, region string, app *domain.App) error { + // Validate inputs before delegating to repository + if err := s.validateApp(app); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appRepo.CreateApp(ctx, region, app); err != nil { + // Map repository errors to domain errors with context + if domain.IsNotFoundError(err) { + return domain.NewAppError(domain.ErrResourceConflict, "CreateApp", app.Key, region, + "app may already exist or have conflicting configuration") + } + return domain.NewAppError(domain.ErrInternalError, "CreateApp", app.Key, region, + "failed to create application").WithDetails(err.Error()) + } + + return nil +} + +func (s *service) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) { + if err := s.validateAppKey(appKey); err != nil { + return nil, err + } + + if region == "" { + return nil, domain.ErrMissingRegion + } + + app, err := s.appRepo.ShowApp(ctx, region, appKey) + if err != nil { + if domain.IsNotFoundError(err) { + return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, + "application does not exist") + } + return nil, domain.NewAppError(domain.ErrInternalError, "ShowApp", appKey, region, + "failed to retrieve application").WithDetails(err.Error()) + } + + return app, nil +} + +func (s *service) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) { + if region == "" { + return nil, domain.ErrMissingRegion + } + + apps, err := s.appRepo.ShowApps(ctx, region, appKey) + if err != nil { + return nil, domain.NewAppError(domain.ErrInternalError, "ShowApps", appKey, region, + "failed to list applications").WithDetails(err.Error()) + } + + return apps, nil +} + +func (s *service) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error { + if err := s.validateAppKey(appKey); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appRepo.DeleteApp(ctx, region, appKey); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewAppError(domain.ErrResourceNotFound, "DeleteApp", appKey, region, + "application does not exist") + } + return domain.NewAppError(domain.ErrInternalError, "DeleteApp", appKey, region, + "failed to delete application").WithDetails(err.Error()) + } + + return nil +} + +func (s *service) UpdateApp(ctx context.Context, region string, app *domain.App) error { + if err := s.validateApp(app); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appRepo.UpdateApp(ctx, region, app); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewAppError(domain.ErrResourceNotFound, "UpdateApp", app.Key, region, + "application does not exist") + } + return domain.NewAppError(domain.ErrInternalError, "UpdateApp", app.Key, region, + "failed to update application").WithDetails(err.Error()) + } + + return nil +} + +// validateApp performs business logic validation on an app +func (s *service) validateApp(app *domain.App) error { + if app == nil { + return domain.NewDomainError(domain.ErrValidationFailed, "application cannot be nil") + } + + if err := s.validateAppKey(app.Key); err != nil { + return err + } + + if strings.TrimSpace(app.ImagePath) == "" { + return domain.NewDomainError(domain.ErrValidationFailed, "image path is required") + } + + if strings.TrimSpace(app.Deployment) == "" { + return domain.NewDomainError(domain.ErrValidationFailed, "deployment type is required") + } + + return nil +} + +// validateAppKey performs business logic validation on an app key +func (s *service) validateAppKey(appKey domain.AppKey) error { + if strings.TrimSpace(appKey.Organization) == "" { + return domain.ErrInvalidAppKey.WithDetails("organization is required") + } + + if strings.TrimSpace(appKey.Name) == "" { + return domain.ErrInvalidAppKey.WithDetails("name is required") + } + + if strings.TrimSpace(appKey.Version) == "" { + return domain.ErrInvalidAppKey.WithDetails("version is required") + } + + return nil +} diff --git a/internal/apply/manager.go b/internal/application/apply/manager.go similarity index 89% rename from internal/apply/manager.go rename to internal/application/apply/manager.go index 45477ab..37d431b 100644 --- a/internal/apply/manager.go +++ b/internal/application/apply/manager.go @@ -7,8 +7,9 @@ import ( "fmt" "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/infrastructure/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven" ) // ResourceManagerInterface defines the interface for resource management @@ -25,7 +26,8 @@ type ResourceManagerInterface interface { // EdgeConnectResourceManager implements resource management for EdgeConnect type EdgeConnectResourceManager struct { - client EdgeConnectClientInterface + appRepo driven.AppRepository + appInstRepo driven.AppInstanceRepository parallelLimit int rollbackOnFail bool logger Logger @@ -66,14 +68,15 @@ func DefaultResourceManagerOptions() ResourceManagerOptions { } // NewResourceManager creates a new EdgeConnect resource manager -func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface { +func NewResourceManager(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface { options := DefaultResourceManagerOptions() for _, opt := range opts { opt(&options) } return &EdgeConnectResourceManager{ - client: client, + appRepo: appRepo, + appInstRepo: appInstRepo, parallelLimit: options.ParallelLimit, rollbackOnFail: options.RollbackOnFail, logger: options.Logger, @@ -133,7 +136,7 @@ func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan strategyConfig := rm.strategyConfig strategyConfig.ParallelOperations = rm.parallelLimit > 1 - factory := NewStrategyFactory(rm.client, strategyConfig, rm.logger) + factory := NewStrategyFactory(rm.appRepo, rm.appInstRepo, strategyConfig, rm.logger) strategy, err := factory.CreateStrategy(strategyName) if err != nil { result := &ExecutionResult{ @@ -190,8 +193,8 @@ func (rm *EdgeConnectResourceManager) ValidatePrerequisites(ctx context.Context, } // Validate that we have required client capabilities - if rm.client == nil { - return fmt.Errorf("EdgeConnect client is not configured") + if rm.appRepo == nil || rm.appInstRepo == nil { + return fmt.Errorf("repositories are not configured") } rm.logf("Prerequisites validation passed") @@ -250,13 +253,13 @@ func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context, // rollbackApp deletes an application that was created func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error { - appKey := edgeconnect.AppKey{ + appKey := domain.AppKey{ Organization: plan.AppAction.Desired.Organization, Name: plan.AppAction.Desired.Name, Version: plan.AppAction.Desired.Version, } - return rm.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region) + return rm.appRepo.DeleteApp(ctx, plan.AppAction.Desired.Region, appKey) } // rollbackInstance deletes an instance that was created @@ -264,15 +267,15 @@ func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, acti // Find the instance action to get the details for _, instanceAction := range plan.InstanceActions { if instanceAction.InstanceName == action.Target { - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := domain.AppInstanceKey{ Organization: plan.AppAction.Desired.Organization, Name: instanceAction.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: domain.CloudletKey{ Organization: instanceAction.Target.CloudletOrg, Name: instanceAction.Target.CloudletName, }, } - return rm.client.DeleteAppInstance(ctx, instanceKey, instanceAction.Target.Region) + return rm.appInstRepo.DeleteAppInstance(ctx, instanceAction.Target.Region, instanceKey) } } return fmt.Errorf("instance action not found for rollback: %s", action.Target) @@ -283,4 +286,4 @@ func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) { if rm.logger != nil { rm.logger.Printf("[ResourceManager] "+format, v...) } -} +} \ No newline at end of file diff --git a/internal/apply/manager_test.go b/internal/application/apply/manager_test.go similarity index 64% rename from internal/apply/manager_test.go rename to internal/application/apply/manager_test.go index 6060a37..8966c8d 100644 --- a/internal/apply/manager_test.go +++ b/internal/application/apply/manager_test.go @@ -10,8 +10,8 @@ import ( "testing" "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/infrastructure/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -19,36 +19,61 @@ import ( // MockResourceClient extends MockEdgeConnectClient with resource management methods type MockResourceClient struct { - MockEdgeConnectClient + mock.Mock } -func (m *MockResourceClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { - args := m.Called(ctx, input) +func (m *MockResourceClient) CreateApp(ctx context.Context, region string, app *domain.App) error { + args := m.Called(ctx, region, app) return args.Error(0) } -func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error { - args := m.Called(ctx, input) +func (m *MockResourceClient) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) { + args := m.Called(ctx, region, appKey) + return args.Get(0).(*domain.App), args.Error(1) +} + +func (m *MockResourceClient) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) { + args := m.Called(ctx, region, appKey) + return args.Get(0).([]domain.App), args.Error(1) +} + +func (m *MockResourceClient) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error { + args := m.Called(ctx, region, appKey) return args.Error(0) } -func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error { - args := m.Called(ctx, appKey, region) +func (m *MockResourceClient) UpdateApp(ctx context.Context, region string, app *domain.App) error { + args := m.Called(ctx, region, app) return args.Error(0) } -func (m *MockResourceClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error { - args := m.Called(ctx, input) +func (m *MockResourceClient) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + args := m.Called(ctx, region, appInst) return args.Error(0) } -func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error { - args := m.Called(ctx, input) +func (m *MockResourceClient) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) { + args := m.Called(ctx, region, appInstKey) + return args.Get(0).(*domain.AppInstance), args.Error(1) +} + +func (m *MockResourceClient) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) { + args := m.Called(ctx, region, appInstKey) + return args.Get(0).([]domain.AppInstance), args.Error(1) +} + +func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + args := m.Called(ctx, region, appInstKey) return args.Error(0) } -func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error { - args := m.Called(ctx, instanceKey, region) +func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + args := m.Called(ctx, region, appInst) + return args.Error(0) +} + +func (m *MockResourceClient) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + args := m.Called(ctx, region, appInstKey) return args.Error(0) } @@ -62,8 +87,9 @@ func (l *TestLogger) Printf(format string, v ...interface{}) { } func TestNewResourceManager(t *testing.T) { - mockClient := &MockResourceClient{} - manager := NewResourceManager(mockClient) + mockAppRepo := &MockResourceClient{} + mockAppInstRepo := &MockResourceClient{} + manager := NewResourceManager(mockAppRepo, mockAppInstRepo) assert.NotNil(t, manager) assert.IsType(t, &EdgeConnectResourceManager{}, manager) @@ -78,10 +104,11 @@ func TestDefaultResourceManagerOptions(t *testing.T) { } func TestWithOptions(t *testing.T) { - mockClient := &MockResourceClient{} + mockAppRepo := &MockResourceClient{} + mockAppInstRepo := &MockResourceClient{} logger := &TestLogger{} - manager := NewResourceManager(mockClient, + manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithParallelLimit(10), WithRollbackOnFail(false), WithLogger(logger), @@ -177,17 +204,18 @@ func createTestStrategyConfig() StrategyConfig { } func TestApplyDeploymentSuccess(t *testing.T) { - mockClient := &MockResourceClient{} + mockAppRepo := &MockResourceClient{} + mockAppInstRepo := &MockResourceClient{} logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) + manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) plan := createTestDeploymentPlan() config := createTestManagerConfig(t) // Mock successful operations - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). + mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")). Return(nil) - mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). + mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")). Return(nil) ctx := context.Background() @@ -204,20 +232,22 @@ func TestApplyDeploymentSuccess(t *testing.T) { // Check that operations were logged assert.Greater(t, len(logger.messages), 0) - mockClient.AssertExpectations(t) + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) } func TestApplyDeploymentAppFailure(t *testing.T) { - mockClient := &MockResourceClient{} + mockAppRepo := &MockResourceClient{} + mockAppInstRepo := &MockResourceClient{} logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) + manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) plan := createTestDeploymentPlan() config := createTestManagerConfig(t) // Mock app creation failure - deployment should stop here - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) + mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")). + Return(fmt.Errorf("Server error")) ctx := context.Background() result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content") @@ -229,25 +259,27 @@ func TestApplyDeploymentAppFailure(t *testing.T) { assert.Len(t, result.FailedActions, 1) assert.Contains(t, err.Error(), "Server error") - mockClient.AssertExpectations(t) + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) } func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { - mockClient := &MockResourceClient{} + mockAppRepo := &MockResourceClient{} + mockAppInstRepo := &MockResourceClient{} logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig())) + manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig())) plan := createTestDeploymentPlan() config := createTestManagerConfig(t) // Mock successful app creation but failed instance creation - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). + mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")). Return(nil) - mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}}) + mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")). + Return(fmt.Errorf("Instance creation failed")) // Mock rollback operations - mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). + mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")). Return(nil) ctx := context.Background() @@ -262,12 +294,14 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { assert.True(t, result.RollbackSuccess) assert.Contains(t, err.Error(), "failed to create instance") - mockClient.AssertExpectations(t) + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) } func TestApplyDeploymentNoActions(t *testing.T) { - mockClient := &MockResourceClient{} - manager := NewResourceManager(mockClient) + mockAppRepo := &MockResourceClient{} + mockAppInstRepo := &MockResourceClient{} + manager := NewResourceManager(mockAppRepo, mockAppInstRepo) // Create empty plan plan := &DeploymentPlan{ @@ -283,14 +317,15 @@ func TestApplyDeploymentNoActions(t *testing.T) { require.NotNil(t, result) assert.Contains(t, err.Error(), "deployment plan is empty") - mockClient.AssertNotCalled(t, "CreateApp") - mockClient.AssertNotCalled(t, "CreateAppInstance") + mockAppRepo.AssertNotCalled(t, "CreateApp") + mockAppInstRepo.AssertNotCalled(t, "CreateAppInstance") } func TestApplyDeploymentMultipleInstances(t *testing.T) { - mockClient := &MockResourceClient{} + mockAppRepo := &MockResourceClient{} + mockAppInstRepo := &MockResourceClient{} logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig())) + manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig())) // Create plan with multiple instances plan := &DeploymentPlan{ @@ -333,9 +368,9 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) { config := createTestManagerConfig(t) // Mock successful operations - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). + mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")). Return(nil) - mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). + mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")). Return(nil) ctx := context.Background() @@ -347,12 +382,14 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) { assert.Len(t, result.CompletedActions, 3) // 1 app + 2 instances assert.Len(t, result.FailedActions, 0) - mockClient.AssertExpectations(t) + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) } func TestValidatePrerequisites(t *testing.T) { - mockClient := &MockResourceClient{} - manager := NewResourceManager(mockClient) + mockAppRepo := &MockResourceClient{} + mockAppInstRepo := &MockResourceClient{} + manager := NewResourceManager(mockAppRepo, mockAppInstRepo) tests := []struct { name string @@ -397,9 +434,10 @@ func TestValidatePrerequisites(t *testing.T) { } func TestRollbackDeployment(t *testing.T) { - mockClient := &MockResourceClient{} + mockAppRepo := &MockResourceClient{} + mockAppInstRepo := &MockResourceClient{} logger := &TestLogger{} - manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) + manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) // Create result with completed actions plan := createTestDeploymentPlan() @@ -421,24 +459,26 @@ func TestRollbackDeployment(t *testing.T) { } // Mock rollback operations - mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). + mockAppInstRepo.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppInstanceKey")). Return(nil) - mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). + mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")). Return(nil) ctx := context.Background() err := manager.RollbackDeployment(ctx, result) require.NoError(t, err) - mockClient.AssertExpectations(t) + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) // Check rollback was logged assert.Greater(t, len(logger.messages), 0) } func TestRollbackDeploymentFailure(t *testing.T) { - mockClient := &MockResourceClient{} - manager := NewResourceManager(mockClient) + mockAppRepo := &MockResourceClient{} + mockAppInstRepo := &MockResourceClient{} + manager := NewResourceManager(mockAppRepo, mockAppInstRepo) plan := createTestDeploymentPlan() result := &ExecutionResult{ @@ -453,15 +493,16 @@ func TestRollbackDeploymentFailure(t *testing.T) { } // Mock rollback failure - mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Delete failed"}}) + mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")). + Return(fmt.Errorf("Delete failed")) ctx := context.Background() err := manager.RollbackDeployment(ctx, result) require.Error(t, err) assert.Contains(t, err.Error(), "rollback encountered") - mockClient.AssertExpectations(t) + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) } func TestConvertNetworkRules(t *testing.T) { diff --git a/internal/application/apply/mocks_test.go b/internal/application/apply/mocks_test.go new file mode 100644 index 0000000..101c1e5 --- /dev/null +++ b/internal/application/apply/mocks_test.go @@ -0,0 +1,138 @@ +package apply + +import ( + "context" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven" + "github.com/stretchr/testify/mock" +) + +// MockAppRepository is a mock implementation of driven.AppRepository +type MockAppRepository struct { + mock.Mock +} + +func (m *MockAppRepository) CreateApp(ctx context.Context, region string, app *domain.App) error { + args := m.Called(ctx, region, app) + return args.Error(0) +} + +func (m *MockAppRepository) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) { + args := m.Called(ctx, region, appKey) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.App), args.Error(1) +} + +func (m *MockAppRepository) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) { + args := m.Called(ctx, region, appKey) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.App), args.Error(1) +} + +func (m *MockAppRepository) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error { + args := m.Called(ctx, region, appKey) + return args.Error(0) +} + +func (m *MockAppRepository) UpdateApp(ctx context.Context, region string, app *domain.App) error { + args := m.Called(ctx, region, app) + return args.Error(0) +} + +// MockAppInstanceRepository is a mock implementation of driven.AppInstanceRepository +type MockAppInstanceRepository struct { + mock.Mock +} + +func (m *MockAppInstanceRepository) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + args := m.Called(ctx, region, appInst) + return args.Error(0) +} + +func (m *MockAppInstanceRepository) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) { + args := m.Called(ctx, region, appInstKey) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.AppInstance), args.Error(1) +} + +func (m *MockAppInstanceRepository) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) { + args := m.Called(ctx, region, appInstKey) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.AppInstance), args.Error(1) +} + +func (m *MockAppInstanceRepository) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + args := m.Called(ctx, region, appInstKey) + return args.Error(0) +} + +func (m *MockAppInstanceRepository) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + args := m.Called(ctx, region, appInst) + return args.Error(0) +} + +func (m *MockAppInstanceRepository) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + args := m.Called(ctx, region, appInstKey) + return args.Error(0) +} + +// MockConfigRepository is a mock implementation of driven.ConfigRepository +type MockConfigRepository struct { + mock.Mock +} + +func (m *MockConfigRepository) ParseFile(path string) (*config.EdgeConnectConfig, string, error) { + args := m.Called(path) + if args.Get(0) == nil { + return nil, args.String(1), args.Error(2) + } + return args.Get(0).(*config.EdgeConnectConfig), args.String(1), args.Error(2) +} + +func (m *MockConfigRepository) Validate(cfg *config.EdgeConnectConfig) error { + args := m.Called(cfg) + return args.Error(0) +} + +// NewTestPlanner creates a planner with mock repositories for testing +func NewTestPlanner(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) Planner { + if appRepo == nil { + appRepo = new(MockAppRepository) + } + if appInstRepo == nil { + appInstRepo = new(MockAppInstanceRepository) + } + return NewPlanner(appRepo, appInstRepo) +} + +// NewTestResourceManager creates a resource manager with mock repositories for testing +func NewTestResourceManager(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) ResourceManagerInterface { + if appRepo == nil { + appRepo = new(MockAppRepository) + } + if appInstRepo == nil { + appInstRepo = new(MockAppInstanceRepository) + } + return NewResourceManager(appRepo, appInstRepo) +} + +// NewTestStrategyFactory creates a strategy factory with mock repositories for testing +func NewTestStrategyFactory(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) *StrategyFactory { + if appRepo == nil { + appRepo = new(MockAppRepository) + } + if appInstRepo == nil { + appInstRepo = new(MockAppInstanceRepository) + } + return NewStrategyFactory(appRepo, appInstRepo, DefaultStrategyConfig(), nil) +} diff --git a/internal/apply/planner.go b/internal/application/apply/planner.go similarity index 88% rename from internal/apply/planner.go rename to internal/application/apply/planner.go index 1cbc58d..6dafcb2 100644 --- a/internal/apply/planner.go +++ b/internal/application/apply/planner.go @@ -11,22 +11,11 @@ import ( "strings" "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/infrastructure/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/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), @@ -470,7 +461,12 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string, if err != nil { return "", fmt.Errorf("failed to open manifest file: %w", err) } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + // Log error but don't fail the operation as hash is already computed + fmt.Fprintf(os.Stderr, "Warning: failed to close manifest file: %v\n", err) + } + }() hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { @@ -505,18 +501,20 @@ func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) ti var duration time.Duration // App operations - if plan.AppAction.Type == ActionCreate { + switch plan.AppAction.Type { + case ActionCreate: duration += 30 * time.Second - } else if plan.AppAction.Type == ActionUpdate { + case ActionUpdate: duration += 15 * time.Second } // Instance operations (can be done in parallel) instanceDuration := time.Duration(0) for _, action := range plan.InstanceActions { - if action.Type == ActionCreate { + switch action.Type { + case ActionCreate: instanceDuration = max(instanceDuration, 2*time.Minute) - } else if action.Type == ActionUpdate { + case ActionUpdate: instanceDuration = max(instanceDuration, 1*time.Minute) } } @@ -552,4 +550,4 @@ func max(a, b time.Duration) time.Duration { // getInstanceName generates the instance name following the pattern: appName-appVersion-instance func getInstanceName(appName, appVersion string) string { return fmt.Sprintf("%s-%s-instance", appName, appVersion) -} +} \ No newline at end of file diff --git a/internal/application/apply/planner_test.go b/internal/application/apply/planner_test.go new file mode 100644 index 0000000..9e99844 --- /dev/null +++ b/internal/application/apply/planner_test.go @@ -0,0 +1,760 @@ +package apply + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" + "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.Spec{ + K8sApp: &config.K8sApp{}, + 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")) + + // Mock app instance not found + mockAppInstRepo.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppInstanceKey")). + 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.Spec{ + K8sApp: &config.K8sApp{}, + 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.Spec{ + K8sApp: &config.K8sApp{}, + 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.Spec{ + K8sApp: &config.K8sApp{}, + 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.Spec{ + K8sApp: &config.K8sApp{ + ManifestFile: 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.Spec{ + K8sApp: &config.K8sApp{}, + 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.Spec{ + K8sApp: &config.K8sApp{}, + 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.Spec{ + K8sApp: &config.K8sApp{}, + 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.NotNil(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.Spec{ + K8sApp: &config.K8sApp{}, + 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.NotNil(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.Spec{ + K8sApp: &config.K8sApp{ + ManifestFile: "/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.NotNil(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.Spec{ + DockerApp: &config.DockerApp{}, + 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: k8s -> docker") + assert.Equal(t, 1, result.Plan.TotalActions) // Only app update, instance is ActionNone + + mockAppRepo.AssertExpectations(t) + mockAppInstRepo.AssertExpectations(t) +} diff --git a/internal/apply/strategy.go b/internal/application/apply/strategy.go similarity index 84% rename from internal/apply/strategy.go rename to internal/application/apply/strategy.go index 8d32d2e..f3c64fc 100644 --- a/internal/apply/strategy.go +++ b/internal/application/apply/strategy.go @@ -7,7 +7,8 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven" ) // DeploymentStrategy represents the type of deployment strategy @@ -66,17 +67,19 @@ func DefaultStrategyConfig() StrategyConfig { // StrategyFactory creates deployment strategy executors type StrategyFactory struct { - config StrategyConfig - client EdgeConnectClientInterface - logger Logger + config StrategyConfig + appRepo driven.AppRepository + appInstRepo driven.AppInstanceRepository + logger Logger } // NewStrategyFactory creates a new strategy factory -func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *StrategyFactory { +func NewStrategyFactory(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, config StrategyConfig, logger Logger) *StrategyFactory { return &StrategyFactory{ - config: config, - client: client, - logger: logger, + config: config, + appRepo: appRepo, + appInstRepo: appInstRepo, + logger: logger, } } @@ -84,7 +87,7 @@ func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) { switch strategy { case StrategyRecreate: - return NewRecreateStrategy(f.client, f.config, f.logger), nil + return NewRecreateStrategy(f.appRepo, f.appInstRepo, f.config, f.logger), nil case StrategyBlueGreen: // TODO: Implement blue-green strategy return nil, fmt.Errorf("blue-green strategy not yet implemented") @@ -103,4 +106,4 @@ func (f *StrategyFactory) GetAvailableStrategies() []DeploymentStrategy { // StrategyBlueGreen, // TODO: Enable when implemented // StrategyRolling, // TODO: Enable when implemented } -} +} \ No newline at end of file diff --git a/internal/apply/strategy_recreate.go b/internal/application/apply/strategy_recreate.go similarity index 82% rename from internal/apply/strategy_recreate.go rename to internal/application/apply/strategy_recreate.go index b2302ca..403aaa6 100644 --- a/internal/apply/strategy_recreate.go +++ b/internal/application/apply/strategy_recreate.go @@ -9,23 +9,26 @@ import ( "sync" "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/infrastructure/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven" ) // RecreateStrategy implements the recreate deployment strategy type RecreateStrategy struct { - client EdgeConnectClientInterface - config StrategyConfig - logger Logger + appRepo driven.AppRepository + appInstRepo driven.AppInstanceRepository + config StrategyConfig + logger Logger } // NewRecreateStrategy creates a new recreate strategy executor -func NewRecreateStrategy(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *RecreateStrategy { +func NewRecreateStrategy(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, config StrategyConfig, logger Logger) *RecreateStrategy { return &RecreateStrategy{ - client: client, - config: config, - logger: logger, + appRepo: appRepo, + appInstRepo: appInstRepo, + config: config, + logger: logger, } } @@ -183,13 +186,13 @@ func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentP r.logf("Phase 2: Deleting existing application") - appKey := edgeconnect.AppKey{ + appKey := domain.AppKey{ Organization: plan.AppAction.Desired.Organization, Name: plan.AppAction.Desired.Name, Version: plan.AppAction.Desired.Version, } - if err := r.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region); err != nil { + if err := r.appRepo.DeleteApp(ctx, plan.AppAction.Desired.Region, appKey); err != nil { result.FailedActions = append(result.FailedActions, ActionResult{ Type: ActionDelete, Target: plan.AppAction.Desired.Name, @@ -386,7 +389,12 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action } } - success, err := r.updateApplication(ctx, action, config, manifestContent) + var success bool + var err error + + // For recreate strategy, we always create the app + success, err = r.createApplication(ctx, action, config, manifestContent) + if success { result.Success = true result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name) @@ -407,16 +415,16 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action // deleteInstance deletes an instance (reuse existing logic from manager.go) func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) { - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := domain.AppInstanceKey{ Organization: action.Desired.Organization, Name: action.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: domain.CloudletKey{ Organization: action.Target.CloudletOrg, Name: action.Target.CloudletName, }, } - err := r.client.DeleteAppInstance(ctx, instanceKey, action.Target.Region) + err := r.appInstRepo.DeleteAppInstance(ctx, action.Target.Region, instanceKey) if err != nil { return false, fmt.Errorf("failed to delete instance: %w", err) } @@ -426,30 +434,27 @@ func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAc // createInstance creates an instance (extracted from manager.go logic) func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) { - instanceInput := &edgeconnect.NewAppInstanceInput{ - Region: action.Target.Region, - AppInst: edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ - Organization: action.Desired.Organization, - Name: action.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ - Organization: action.Target.CloudletOrg, - Name: action.Target.CloudletName, - }, - }, - AppKey: edgeconnect.AppKey{ - Organization: action.Desired.Organization, - Name: config.Metadata.Name, - Version: config.Metadata.AppVersion, - }, - Flavor: edgeconnect.Flavor{ - Name: action.Target.FlavorName, + appInst := &domain.AppInstance{ + Key: domain.AppInstanceKey{ + Organization: action.Desired.Organization, + Name: action.InstanceName, + CloudletKey: domain.CloudletKey{ + Organization: action.Target.CloudletOrg, + Name: action.Target.CloudletName, }, }, + AppKey: domain.AppKey{ + Organization: action.Desired.Organization, + Name: config.Metadata.Name, + Version: config.Metadata.AppVersion, + }, + Flavor: domain.Flavor{ + Name: action.Target.FlavorName, + }, } // Create the instance - if err := r.client.CreateAppInstance(ctx, instanceInput); err != nil { + if err := r.appInstRepo.CreateAppInstance(ctx, action.Target.Region, appInst); err != nil { return false, fmt.Errorf("failed to create instance: %w", err) } @@ -459,35 +464,30 @@ func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAc return true, nil } -// updateApplication creates/recreates an application (always uses CreateApp since we delete first) -func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) { - // Build the app create input - always create since recreate strategy deletes first - appInput := &edgeconnect.NewAppInput{ - Region: action.Desired.Region, - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ - Organization: action.Desired.Organization, - Name: action.Desired.Name, - Version: action.Desired.Version, - }, - Deployment: config.GetDeploymentType(), - ImageType: "ImageTypeDocker", - ImagePath: config.GetImagePath(), - AllowServerless: true, - DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, - ServerlessConfig: struct{}{}, - DeploymentManifest: manifestContent, - DeploymentGenerator: "kubernetes-basic", +// createApplication creates/recreates an application (always uses CreateApp since we delete first) +func (r *RecreateStrategy) createApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) { + app := &domain.App{ + Key: domain.AppKey{ + Organization: action.Desired.Organization, + Name: action.Desired.Name, + Version: action.Desired.Version, }, + Deployment: config.GetDeploymentType(), + ImagePath: config.GetImagePath(), + AllowServerless: true, + DefaultFlavor: domain.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, + ServerlessConfig: struct{}{}, + DeploymentManifest: manifestContent, + DeploymentGenerator: "kubernetes-basic", } // Add network configuration if specified if config.Spec.Network != nil { - appInput.App.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network) + app.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network) } // Create the application (recreate strategy always creates from scratch) - if err := r.client.CreateApp(ctx, appInput); err != nil { + if err := r.appRepo.CreateApp(ctx, action.Desired.Region, app); err != nil { return false, fmt.Errorf("failed to create application: %w", err) } @@ -497,9 +497,27 @@ func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppActi return true, nil } +// convertNetworkRules converts config.NetworkConfig to []domain.SecurityRule +func convertNetworkRules(network *config.NetworkConfig) []domain.SecurityRule { + if network == nil || len(network.OutboundConnections) == 0 { + return nil + } + + rules := make([]domain.SecurityRule, len(network.OutboundConnections)) + for i, conn := range network.OutboundConnections { + rules[i] = domain.SecurityRule{ + Protocol: conn.Protocol, + PortRangeMin: conn.PortRangeMin, + PortRangeMax: conn.PortRangeMax, + RemoteCIDR: conn.RemoteCIDR, + } + } + return rules +} + // logf logs a message if a logger is configured func (r *RecreateStrategy) logf(format string, v ...interface{}) { if r.logger != nil { r.logger.Printf("[RecreateStrategy] "+format, v...) } -} +} \ No newline at end of file diff --git a/internal/application/apply/types.go b/internal/application/apply/types.go new file mode 100644 index 0000000..451043c --- /dev/null +++ b/internal/application/apply/types.go @@ -0,0 +1,161 @@ +// ABOUTME: Core types for EdgeConnect deployment planning and execution +// ABOUTME: Defines data structures for deployment plans, actions, and results +package apply + +import ( + "fmt" + "strings" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" +) + +// 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 = "k8s" + 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 +} diff --git a/internal/application/cloudlet/service.go b/internal/application/cloudlet/service.go new file mode 100644 index 0000000..78804f8 --- /dev/null +++ b/internal/application/cloudlet/service.go @@ -0,0 +1,118 @@ +package cloudlet + +import ( + "context" + "strings" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving" +) + +type service struct { + cloudletRepo driven.CloudletRepository +} + +func NewService(cloudletRepo driven.CloudletRepository) driving.CloudletService { + return &service{cloudletRepo: cloudletRepo} +} + +func (s *service) CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error { + if err := s.validateCloudlet(cloudlet); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.cloudletRepo.CreateCloudlet(ctx, region, cloudlet); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewCloudletError(domain.ErrResourceConflict, "CreateCloudlet", cloudlet.Key, region, + "cloudlet may already exist or have conflicting configuration") + } + return domain.NewCloudletError(domain.ErrInternalError, "CreateCloudlet", cloudlet.Key, region, + "failed to create cloudlet").WithDetails(err.Error()) + } + + return nil +} + +func (s *service) ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) { + if err := s.validateCloudletKey(cloudletKey); err != nil { + return nil, err + } + + if region == "" { + return nil, domain.ErrMissingRegion + } + + cloudlet, err := s.cloudletRepo.ShowCloudlet(ctx, region, cloudletKey) + if err != nil { + if domain.IsNotFoundError(err) { + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, + "cloudlet does not exist") + } + return nil, domain.NewCloudletError(domain.ErrInternalError, "ShowCloudlet", cloudletKey, region, + "failed to retrieve cloudlet").WithDetails(err.Error()) + } + + return cloudlet, nil +} + +func (s *service) ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) { + if region == "" { + return nil, domain.ErrMissingRegion + } + + cloudlets, err := s.cloudletRepo.ShowCloudlets(ctx, region, cloudletKey) + if err != nil { + return nil, domain.NewCloudletError(domain.ErrInternalError, "ShowCloudlets", cloudletKey, region, + "failed to list cloudlets").WithDetails(err.Error()) + } + + return cloudlets, nil +} + +func (s *service) DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error { + if err := s.validateCloudletKey(cloudletKey); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.cloudletRepo.DeleteCloudlet(ctx, region, cloudletKey); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewCloudletError(domain.ErrResourceNotFound, "DeleteCloudlet", cloudletKey, region, + "cloudlet does not exist") + } + return domain.NewCloudletError(domain.ErrInternalError, "DeleteCloudlet", cloudletKey, region, + "failed to delete cloudlet").WithDetails(err.Error()) + } + + return nil +} + +// validateCloudlet performs business logic validation on a cloudlet +func (s *service) validateCloudlet(cloudlet *domain.Cloudlet) error { + if cloudlet == nil { + return domain.NewDomainError(domain.ErrValidationFailed, "cloudlet cannot be nil") + } + + return s.validateCloudletKey(cloudlet.Key) +} + +// validateCloudletKey performs business logic validation on a cloudlet key +func (s *service) validateCloudletKey(cloudletKey domain.CloudletKey) error { + if strings.TrimSpace(cloudletKey.Organization) == "" { + return domain.ErrInvalidCloudletKey.WithDetails("organization is required") + } + + if strings.TrimSpace(cloudletKey.Name) == "" { + return domain.ErrInvalidCloudletKey.WithDetails("name is required") + } + + return nil +} diff --git a/internal/application/instance/service.go b/internal/application/instance/service.go new file mode 100644 index 0000000..276f770 --- /dev/null +++ b/internal/application/instance/service.go @@ -0,0 +1,185 @@ +package instance + +import ( + "context" + "strings" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving" +) + +type service struct { + appInstanceRepo driven.AppInstanceRepository +} + +func NewService(appInstanceRepo driven.AppInstanceRepository) driving.AppInstanceService { + return &service{appInstanceRepo: appInstanceRepo} +} + +func (s *service) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + if err := s.validateAppInstance(appInst); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appInstanceRepo.CreateAppInstance(ctx, region, appInst); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewInstanceError(domain.ErrResourceConflict, "CreateAppInstance", appInst.Key, region, + "app instance may already exist or have conflicting configuration") + } + return domain.NewInstanceError(domain.ErrInternalError, "CreateAppInstance", appInst.Key, region, + "failed to create app instance").WithDetails(err.Error()) + } + + return nil +} + +func (s *service) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) { + if err := s.validateAppInstanceKey(appInstKey); err != nil { + return nil, err + } + + if region == "" { + return nil, domain.ErrMissingRegion + } + + instance, err := s.appInstanceRepo.ShowAppInstance(ctx, region, appInstKey) + if err != nil { + if domain.IsNotFoundError(err) { + return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, + "app instance does not exist") + } + return nil, domain.NewInstanceError(domain.ErrInternalError, "ShowAppInstance", appInstKey, region, + "failed to retrieve app instance").WithDetails(err.Error()) + } + + return instance, nil +} + +func (s *service) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) { + if region == "" { + return nil, domain.ErrMissingRegion + } + + instances, err := s.appInstanceRepo.ShowAppInstances(ctx, region, appInstKey) + if err != nil { + return nil, domain.NewInstanceError(domain.ErrInternalError, "ShowAppInstances", appInstKey, region, + "failed to list app instances").WithDetails(err.Error()) + } + + return instances, nil +} + +func (s *service) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + if err := s.validateAppInstanceKey(appInstKey); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appInstanceRepo.DeleteAppInstance(ctx, region, appInstKey); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewInstanceError(domain.ErrResourceNotFound, "DeleteAppInstance", appInstKey, region, + "app instance does not exist") + } + return domain.NewInstanceError(domain.ErrInternalError, "DeleteAppInstance", appInstKey, region, + "failed to delete app instance").WithDetails(err.Error()) + } + + return nil +} + +func (s *service) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + if err := s.validateAppInstance(appInst); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appInstanceRepo.UpdateAppInstance(ctx, region, appInst); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewInstanceError(domain.ErrResourceConflict, "UpdateAppInstance", appInst.Key, region, + "app instance may already exist or have conflicting configuration") + } + return domain.NewInstanceError(domain.ErrInternalError, "UpdateAppInstance", appInst.Key, region, + "failed to update app instance").WithDetails(err.Error()) + } + + return nil +} + +func (s *service) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + if err := s.validateAppInstanceKey(appInstKey); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + // Note: The driven port (repository) does not currently have a Refresh method. + // This is a placeholder implementation. + // To fully implement this, we would need to add RefreshAppInstance to the AppInstanceRepository interface + // and implement it in the edgeconnect adapter. + // For now, we can just return nil or a 'not implemented' error. + // Let's delegate to the Show method as a temporary measure. + _, err := s.appInstanceRepo.ShowAppInstance(ctx, region, appInstKey) + if err != nil { + if domain.IsNotFoundError(err) { + return domain.NewInstanceError(domain.ErrResourceNotFound, "RefreshAppInstance", appInstKey, region, + "app instance does not exist") + } + return domain.NewInstanceError(domain.ErrInternalError, "RefreshAppInstance", appInstKey, region, + "failed to refresh app instance").WithDetails(err.Error()) + } + + return nil +} + +// validateAppInstance performs business logic validation on an app instance +func (s *service) validateAppInstance(appInst *domain.AppInstance) error { + if appInst == nil { + return domain.NewDomainError(domain.ErrValidationFailed, "app instance cannot be nil") + } + + if err := s.validateAppInstanceKey(appInst.Key); err != nil { + return err + } + + // Validate flavor if present + if strings.TrimSpace(appInst.Flavor.Name) == "" { + return domain.NewDomainError(domain.ErrValidationFailed, "flavor name is required") + } + + return nil +} + +// validateAppInstanceKey performs business logic validation on an app instance key +func (s *service) validateAppInstanceKey(appInstKey domain.AppInstanceKey) error { + if strings.TrimSpace(appInstKey.Organization) == "" { + return domain.ErrInvalidInstanceKey.WithDetails("organization is required") + } + + if strings.TrimSpace(appInstKey.Name) == "" { + return domain.ErrInvalidInstanceKey.WithDetails("name is required") + } + + // Validate embedded cloudlet key + if strings.TrimSpace(appInstKey.CloudletKey.Organization) == "" { + return domain.ErrInvalidInstanceKey.WithDetails("cloudlet organization is required") + } + + if strings.TrimSpace(appInstKey.CloudletKey.Name) == "" { + return domain.ErrInvalidInstanceKey.WithDetails("cloudlet name is required") + } + + return nil +} diff --git a/internal/application/organization/service.go b/internal/application/organization/service.go new file mode 100644 index 0000000..978d8c0 --- /dev/null +++ b/internal/application/organization/service.go @@ -0,0 +1,63 @@ +package organization + +import ( + "context" + "strings" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving" +) + +// service implements the OrganizationService interface and provides the core business logic. +type service struct { + repo driven.OrganizationRepository +} + +// NewService creates a new organization service with the given repository. +func NewService(repo driven.OrganizationRepository) driving.OrganizationService { + return &service{repo: repo} +} + +// CreateOrganization validates the organization and passes it to the repository for creation. +func (s *service) Create(ctx context.Context, org *domain.Organization) error { + if err := s.validateOrganization(org); err != nil { + return err + } + return s.repo.CreateOrganization(ctx, org) +} + +// Get retrieves an organization by name. +func (s *service) Get(ctx context.Context, name string) (*domain.Organization, error) { + if strings.TrimSpace(name) == "" { + return nil, domain.NewDomainError(domain.ErrValidationFailed, "organization name cannot be empty") + } + return s.repo.ShowOrganization(ctx, name) +} + +// Update validates the organization and passes it to the repository for updates. +func (s *service) Update(ctx context.Context, org *domain.Organization) error { + if err := s.validateOrganization(org); err != nil { + return err + } + return s.repo.UpdateOrganization(ctx, org) +} + +// Delete removes an organization by name. +func (s *service) Delete(ctx context.Context, name string) error { + if strings.TrimSpace(name) == "" { + return domain.NewDomainError(domain.ErrValidationFailed, "organization name cannot be empty") + } + return s.repo.DeleteOrganization(ctx, name) +} + +// validateOrganization contains the business logic for validating an organization. +func (s *service) validateOrganization(org *domain.Organization) error { + if org == nil { + return domain.NewDomainError(domain.ErrValidationFailed, "organization cannot be nil") + } + if strings.TrimSpace(org.Name) == "" { + return domain.NewDomainError(domain.ErrValidationFailed, "organization name cannot be empty") + } + return nil +} diff --git a/internal/apply/planner_test.go b/internal/apply/planner_test.go deleted file mode 100644 index d946a14..0000000 --- a/internal/apply/planner_test.go +++ /dev/null @@ -1,663 +0,0 @@ -// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios -// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios -package apply - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -// MockEdgeConnectClient is a mock implementation of the EdgeConnect client -type MockEdgeConnectClient struct { - mock.Mock -} - -func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) { - args := m.Called(ctx, appKey, region) - if args.Get(0) == nil { - return edgeconnect.App{}, args.Error(1) - } - return args.Get(0).(edgeconnect.App), args.Error(1) -} - -func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) { - args := m.Called(ctx, instanceKey, region) - if args.Get(0) == nil { - return edgeconnect.AppInstance{}, args.Error(1) - } - return args.Get(0).(edgeconnect.AppInstance), args.Error(1) -} - -func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { - args := m.Called(ctx, input) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error { - args := m.Called(ctx, input) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error { - args := m.Called(ctx, appKey, region) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error { - args := m.Called(ctx, input) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error { - args := m.Called(ctx, input) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error { - args := m.Called(ctx, instanceKey, region) - return args.Error(0) -} - -func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect.AppKey, region string) ([]edgeconnect.App, error) { - args := m.Called(ctx, appKey, region) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]edgeconnect.App), args.Error(1) -} - -func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) { - args := m.Called(ctx, instanceKey, region) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]edgeconnect.AppInstance), args.Error(1) -} - -func TestNewPlanner(t *testing.T) { - mockClient := &MockEdgeConnectClient{} - planner := NewPlanner(mockClient) - - assert.NotNil(t, planner) - assert.IsType(t, &EdgeConnectPlanner{}, planner) -} - -func TestDefaultPlanOptions(t *testing.T) { - opts := DefaultPlanOptions() - - assert.False(t, opts.DryRun) - assert.False(t, opts.Force) - assert.False(t, opts.SkipStateCheck) - assert.True(t, opts.ParallelQueries) - assert.Equal(t, 30*time.Second, opts.Timeout) -} - -func createTestConfig(t *testing.T) *config.EdgeConnectConfig { - // Create temporary manifest file - tempDir := t.TempDir() - manifestFile := filepath.Join(tempDir, "test-manifest.yaml") - manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" - err := os.WriteFile(manifestFile, []byte(manifestContent), 0644) - require.NoError(t, err) - - return &config.EdgeConnectConfig{ - Kind: "edgeconnect-deployment", - Metadata: config.Metadata{ - Name: "test-app", - AppVersion: "1.0.0", - Organization: "testorg", - }, - Spec: config.Spec{ - K8sApp: &config.K8sApp{ - ManifestFile: manifestFile, - }, - InfraTemplate: []config.InfraTemplate{ - { - Region: "US", - CloudletOrg: "TestCloudletOrg", - CloudletName: "TestCloudlet", - FlavorName: "small", - }, - }, - Network: &config.NetworkConfig{ - OutboundConnections: []config.OutboundConnection{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - }, - }, - }, - } -} - -func TestPlanNewDeployment(t *testing.T) { - mockClient := &MockEdgeConnectClient{} - planner := NewPlanner(mockClient) - testConfig := createTestConfig(t) - - // Mock API calls to return "not found" errors - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) - - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) - - ctx := context.Background() - result, err := planner.Plan(ctx, testConfig) - - require.NoError(t, err) - require.NotNil(t, result) - require.NotNil(t, result.Plan) - require.NoError(t, result.Error) - - plan := result.Plan - assert.Equal(t, "test-app", plan.ConfigName) - assert.Equal(t, ActionCreate, plan.AppAction.Type) - assert.Equal(t, "Application does not exist", plan.AppAction.Reason) - - require.Len(t, plan.InstanceActions, 1) - assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type) - assert.Equal(t, "Instance does not exist", plan.InstanceActions[0].Reason) - - assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance - assert.False(t, plan.IsEmpty()) - - mockClient.AssertExpectations(t) -} - -func TestPlanExistingDeploymentNoChanges(t *testing.T) { - mockClient := &MockEdgeConnectClient{} - planner := NewPlanner(mockClient) - testConfig := createTestConfig(t) - - // Note: We would calculate expected manifest hash here when API supports it - - // Mock existing app with same manifest hash and outbound connections - manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" - existingApp := &edgeconnect.App{ - Key: edgeconnect.AppKey{ - Organization: "testorg", - Name: "test-app", - Version: "1.0.0", - }, - Deployment: "kubernetes", - DeploymentManifest: manifestContent, - RequiredOutboundConnections: []edgeconnect.SecurityRule{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - }, - // Note: Manifest hash tracking would be implemented when API supports annotations - } - - // Mock existing instance - existingInstance := &edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ - Organization: "testorg", - Name: "test-app-1.0.0-instance", - CloudletKey: edgeconnect.CloudletKey{ - Organization: "TestCloudletOrg", - Name: "TestCloudlet", - }, - }, - AppKey: edgeconnect.AppKey{ - Organization: "testorg", - Name: "test-app", - Version: "1.0.0", - }, - Flavor: edgeconnect.Flavor{ - Name: "small", - }, - State: "Ready", - PowerState: "PowerOn", - } - - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(*existingApp, nil) - - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). - Return(*existingInstance, nil) - - ctx := context.Background() - result, err := planner.Plan(ctx, testConfig) - - require.NoError(t, err) - require.NotNil(t, result) - require.NotNil(t, result.Plan) - - plan := result.Plan - assert.Equal(t, ActionNone, plan.AppAction.Type) - assert.Len(t, plan.InstanceActions, 1) - assert.Equal(t, ActionNone, plan.InstanceActions[0].Type) - assert.Equal(t, 0, plan.TotalActions) - assert.True(t, plan.IsEmpty()) - assert.Contains(t, plan.Summary, "No changes required") - - mockClient.AssertExpectations(t) -} - -func TestPlanWithOptions(t *testing.T) { - mockClient := &MockEdgeConnectClient{} - planner := NewPlanner(mockClient) - testConfig := createTestConfig(t) - - opts := PlanOptions{ - DryRun: true, - SkipStateCheck: true, - Timeout: 10 * time.Second, - } - - ctx := context.Background() - result, err := planner.PlanWithOptions(ctx, testConfig, opts) - - require.NoError(t, err) - require.NotNil(t, result) - require.NotNil(t, result.Plan) - - plan := result.Plan - assert.True(t, plan.DryRun) - assert.Equal(t, ActionCreate, plan.AppAction.Type) - assert.Contains(t, plan.AppAction.Reason, "state check skipped") - - // No API calls should be made when SkipStateCheck is true - mockClient.AssertNotCalled(t, "ShowApp") - mockClient.AssertNotCalled(t, "ShowAppInstance") -} - -func TestPlanMultipleInfrastructures(t *testing.T) { - mockClient := &MockEdgeConnectClient{} - planner := NewPlanner(mockClient) - testConfig := createTestConfig(t) - - // Add a second infrastructure target - testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{ - Region: "EU", - CloudletOrg: "EUCloudletOrg", - CloudletName: "EUCloudlet", - FlavorName: "medium", - }) - - // Mock API calls to return "not found" errors - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) - - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) - - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "EU"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) - - ctx := context.Background() - result, err := planner.Plan(ctx, testConfig) - - require.NoError(t, err) - require.NotNil(t, result) - require.NotNil(t, result.Plan) - - plan := result.Plan - assert.Equal(t, ActionCreate, plan.AppAction.Type) - - // Should have 2 instance actions, one for each infrastructure - require.Len(t, plan.InstanceActions, 2) - assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type) - assert.Equal(t, ActionCreate, plan.InstanceActions[1].Type) - - assert.Equal(t, 3, plan.TotalActions) // 1 app + 2 instances - - // Test cloudlet and region aggregation - cloudlets := plan.GetTargetCloudlets() - regions := plan.GetTargetRegions() - assert.Len(t, cloudlets, 2) - assert.Len(t, regions, 2) - - mockClient.AssertExpectations(t) -} - -func TestCalculateManifestHash(t *testing.T) { - planner := &EdgeConnectPlanner{} - tempDir := t.TempDir() - - // Create test file - testFile := filepath.Join(tempDir, "test.yaml") - content := "test content for hashing" - err := os.WriteFile(testFile, []byte(content), 0644) - require.NoError(t, err) - - hash1, err := planner.calculateManifestHash(testFile) - require.NoError(t, err) - assert.NotEmpty(t, hash1) - assert.Len(t, hash1, 64) // SHA256 hex string length - - // Same content should produce same hash - hash2, err := planner.calculateManifestHash(testFile) - require.NoError(t, err) - assert.Equal(t, hash1, hash2) - - // Different content should produce different hash - err = os.WriteFile(testFile, []byte("different content"), 0644) - require.NoError(t, err) - - hash3, err := planner.calculateManifestHash(testFile) - require.NoError(t, err) - assert.NotEqual(t, hash1, hash3) - - // Empty file path should return empty hash - hash4, err := planner.calculateManifestHash("") - require.NoError(t, err) - assert.Empty(t, hash4) - - // Non-existent file should return error - _, err = planner.calculateManifestHash("/non/existent/file") - assert.Error(t, err) -} - -func TestCompareAppStates(t *testing.T) { - planner := &EdgeConnectPlanner{} - - current := &AppState{ - Name: "test-app", - Version: "1.0.0", - AppType: AppTypeK8s, - ManifestHash: "old-hash", - } - - desired := &AppState{ - Name: "test-app", - Version: "1.0.0", - AppType: AppTypeK8s, - ManifestHash: "new-hash", - } - - changes, manifestChanged := planner.compareAppStates(current, desired) - assert.Len(t, changes, 1) - assert.True(t, manifestChanged) - assert.Contains(t, changes[0], "Manifest hash changed") - - // Test no changes - desired.ManifestHash = "old-hash" - changes, manifestChanged = planner.compareAppStates(current, desired) - assert.Empty(t, changes) - assert.False(t, manifestChanged) - - // Test app type change - desired.AppType = AppTypeDocker - changes, manifestChanged = planner.compareAppStates(current, desired) - assert.Len(t, changes, 1) - assert.False(t, manifestChanged) - assert.Contains(t, changes[0], "App type changed") -} - -func TestCompareAppStatesOutboundConnections(t *testing.T) { - planner := &EdgeConnectPlanner{} - - // Test with no outbound connections - current := &AppState{ - Name: "test-app", - Version: "1.0.0", - AppType: AppTypeK8s, - OutboundConnections: nil, - } - - desired := &AppState{ - Name: "test-app", - Version: "1.0.0", - AppType: AppTypeK8s, - OutboundConnections: nil, - } - - changes, _ := planner.compareAppStates(current, desired) - assert.Empty(t, changes, "No changes expected when both have no outbound connections") - - // Test adding outbound connections - desired.OutboundConnections = []SecurityRule{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - } - - changes, _ = planner.compareAppStates(current, desired) - assert.Len(t, changes, 1) - assert.Contains(t, changes[0], "Outbound connections changed") - - // Test identical outbound connections - current.OutboundConnections = []SecurityRule{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - } - - changes, _ = planner.compareAppStates(current, desired) - assert.Empty(t, changes, "No changes expected when outbound connections are identical") - - // Test different outbound connections (different port) - desired.OutboundConnections[0].PortRangeMin = 443 - desired.OutboundConnections[0].PortRangeMax = 443 - - changes, _ = planner.compareAppStates(current, desired) - assert.Len(t, changes, 1) - assert.Contains(t, changes[0], "Outbound connections changed") - - // Test same connections but different order (should be considered equal) - current.OutboundConnections = []SecurityRule{ - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - { - Protocol: "tcp", - PortRangeMin: 443, - PortRangeMax: 443, - RemoteCIDR: "0.0.0.0/0", - }, - } - - desired.OutboundConnections = []SecurityRule{ - { - Protocol: "tcp", - PortRangeMin: 443, - PortRangeMax: 443, - RemoteCIDR: "0.0.0.0/0", - }, - { - Protocol: "tcp", - PortRangeMin: 80, - PortRangeMax: 80, - RemoteCIDR: "0.0.0.0/0", - }, - } - - changes, _ = planner.compareAppStates(current, desired) - assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order") - - // Test removing outbound connections - desired.OutboundConnections = nil - - changes, _ = planner.compareAppStates(current, desired) - assert.Len(t, changes, 1) - assert.Contains(t, changes[0], "Outbound connections changed") -} - -func TestCompareInstanceStates(t *testing.T) { - planner := &EdgeConnectPlanner{} - - current := &InstanceState{ - Name: "test-instance", - FlavorName: "small", - CloudletName: "oldcloudlet", - CloudletOrg: "oldorg", - } - - desired := &InstanceState{ - Name: "test-instance", - FlavorName: "medium", - CloudletName: "newcloudlet", - CloudletOrg: "neworg", - } - - changes := planner.compareInstanceStates(current, desired) - assert.Len(t, changes, 3) - assert.Contains(t, changes[0], "Flavor changed") - assert.Contains(t, changes[1], "Cloudlet changed") - assert.Contains(t, changes[2], "Cloudlet org changed") - - // Test no changes - desired.FlavorName = "small" - desired.CloudletName = "oldcloudlet" - desired.CloudletOrg = "oldorg" - changes = planner.compareInstanceStates(current, desired) - assert.Empty(t, changes) -} - -func TestDeploymentPlanMethods(t *testing.T) { - plan := &DeploymentPlan{ - ConfigName: "test-plan", - AppAction: AppAction{ - Type: ActionCreate, - Desired: &AppState{Name: "test-app"}, - }, - InstanceActions: []InstanceAction{ - { - Type: ActionCreate, - Target: config.InfraTemplate{ - CloudletOrg: "org1", - CloudletName: "cloudlet1", - Region: "US", - }, - InstanceName: "instance1", - Desired: &InstanceState{Name: "instance1"}, - }, - { - Type: ActionUpdate, - Target: config.InfraTemplate{ - CloudletOrg: "org2", - CloudletName: "cloudlet2", - Region: "EU", - }, - InstanceName: "instance2", - Desired: &InstanceState{Name: "instance2"}, - }, - }, - } - - // Test IsEmpty - assert.False(t, plan.IsEmpty()) - - // Test GetTargetCloudlets - cloudlets := plan.GetTargetCloudlets() - assert.Len(t, cloudlets, 2) - assert.Contains(t, cloudlets, "org1:cloudlet1") - assert.Contains(t, cloudlets, "org2:cloudlet2") - - // Test GetTargetRegions - regions := plan.GetTargetRegions() - assert.Len(t, regions, 2) - assert.Contains(t, regions, "US") - assert.Contains(t, regions, "EU") - - // Test GenerateSummary - summary := plan.GenerateSummary() - assert.Contains(t, summary, "test-plan") - assert.Contains(t, summary, "CREATE application") - assert.Contains(t, summary, "CREATE 1 instance") - assert.Contains(t, summary, "UPDATE 1 instance") - - // Test Validate - err := plan.Validate() - assert.NoError(t, err) - - // Test validation failure - plan.AppAction.Desired = nil - err = plan.Validate() - assert.Error(t, err) - assert.Contains(t, err.Error(), "must have desired state") -} - -func TestEstimateDeploymentDuration(t *testing.T) { - planner := &EdgeConnectPlanner{} - - plan := &DeploymentPlan{ - AppAction: AppAction{Type: ActionCreate}, - InstanceActions: []InstanceAction{ - {Type: ActionCreate}, - {Type: ActionUpdate}, - }, - } - - duration := planner.estimateDeploymentDuration(plan) - assert.Greater(t, duration, time.Duration(0)) - assert.Less(t, duration, 10*time.Minute) // Reasonable upper bound - - // Test with no actions - emptyPlan := &DeploymentPlan{ - AppAction: AppAction{Type: ActionNone}, - InstanceActions: []InstanceAction{}, - } - - emptyDuration := planner.estimateDeploymentDuration(emptyPlan) - assert.Greater(t, emptyDuration, time.Duration(0)) - assert.Less(t, emptyDuration, duration) // Should be less than plan with actions -} - -func TestIsResourceNotFoundError(t *testing.T) { - tests := []struct { - name string - err error - expected bool - }{ - {"nil error", nil, false}, - {"not found error", &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true}, - {"does not exist error", &edgeconnect.APIError{Messages: []string{"App does not exist"}}, true}, - {"404 in message", &edgeconnect.APIError{Messages: []string{"HTTP 404 error"}}, true}, - {"other error", &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isResourceNotFoundError(tt.err) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestPlanErrorHandling(t *testing.T) { - mockClient := &MockEdgeConnectClient{} - planner := NewPlanner(mockClient) - testConfig := createTestConfig(t) - - // Mock API call to return a non-404 error - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) - - ctx := context.Background() - result, err := planner.Plan(ctx, testConfig) - - assert.Error(t, err) - assert.NotNil(t, result) - assert.NotNil(t, result.Error) - assert.Contains(t, err.Error(), "failed to query current app state") - - mockClient.AssertExpectations(t) -} diff --git a/internal/apply/types.go b/internal/apply/types.go deleted file mode 100644 index 6f7ef4e..0000000 --- a/internal/apply/types.go +++ /dev/null @@ -1,462 +0,0 @@ -// ABOUTME: Deployment planning types for EdgeConnect apply command with state management -// ABOUTME: Defines structures for deployment plans, actions, and state comparison results -package apply - -import ( - "fmt" - "strings" - "time" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" -) - -// SecurityRule defines network access rules (alias to SDK type for consistency) -type SecurityRule = edgeconnect.SecurityRule - -// ActionType represents the type of action to be performed -type ActionType string - -const ( - // ActionCreate indicates a resource needs to be created - ActionCreate ActionType = "CREATE" - // ActionUpdate indicates a resource needs to be updated - ActionUpdate ActionType = "UPDATE" - // ActionNone indicates no action is needed - ActionNone ActionType = "NONE" - // ActionDelete indicates a resource needs to be deleted (for rollback scenarios) - ActionDelete ActionType = "DELETE" -) - -// String returns the string representation of ActionType -func (a ActionType) String() string { - return string(a) -} - -// DeploymentPlan represents the complete deployment plan for a configuration -type DeploymentPlan struct { - // ConfigName is the name from metadata - ConfigName string - - // AppAction defines what needs to be done with the application - AppAction AppAction - - // InstanceActions defines what needs to be done with each instance - InstanceActions []InstanceAction - - // Summary provides a human-readable summary of the plan - Summary string - - // TotalActions is the count of all actions that will be performed - TotalActions int - - // EstimatedDuration is the estimated time to complete the deployment - EstimatedDuration time.Duration - - // CreatedAt timestamp when the plan was created - CreatedAt time.Time - - // DryRun indicates if this is a dry-run plan - DryRun bool -} - -// AppAction represents an action to be performed on an application -type AppAction struct { - // Type of action to perform - Type ActionType - - // Current state of the app (nil if doesn't exist) - Current *AppState - - // Desired state of the app - Desired *AppState - - // Changes describes what will change - Changes []string - - // Reason explains why this action is needed - Reason string - - // ManifestHash is the hash of the current manifest file - ManifestHash string - - // ManifestChanged indicates if the manifest content has changed - ManifestChanged bool -} - -// InstanceAction represents an action to be performed on an application instance -type InstanceAction struct { - // Type of action to perform - Type ActionType - - // Target infrastructure where the instance will be deployed - Target config.InfraTemplate - - // Current state of the instance (nil if doesn't exist) - Current *InstanceState - - // Desired state of the instance - Desired *InstanceState - - // Changes describes what will change - Changes []string - - // Reason explains why this action is needed - Reason string - - // InstanceName is the generated name for this instance - InstanceName string - - // Dependencies lists other instances this depends on - Dependencies []string -} - -// AppState represents the current state of an application -type AppState struct { - // Name of the application - Name string - - // Version of the application - Version string - - // Organization that owns the app - Organization string - - // Region where the app is deployed - Region string - - // ManifestHash is the stored hash of the manifest file - ManifestHash string - - // LastUpdated timestamp when the app was last modified - LastUpdated time.Time - - // Exists indicates if the app currently exists - Exists bool - - // AppType indicates whether this is a k8s or docker app - AppType AppType - - // OutboundConnections contains the required outbound network connections - OutboundConnections []SecurityRule -} - -// InstanceState represents the current state of an application instance -type InstanceState struct { - // Name of the instance - Name string - - // AppName that this instance belongs to - AppName string - - // AppVersion of the associated app - AppVersion string - - // Organization that owns the instance - Organization string - - // Region where the instance is deployed - Region string - - // CloudletOrg that hosts the cloudlet - CloudletOrg string - - // CloudletName where the instance is running - CloudletName string - - // FlavorName used for the instance - FlavorName string - - // State of the instance (e.g., "Ready", "Pending", "Error") - State string - - // PowerState of the instance - PowerState string - - // LastUpdated timestamp when the instance was last modified - LastUpdated time.Time - - // Exists indicates if the instance currently exists - Exists bool -} - -// AppType represents the type of application -type AppType string - -const ( - // AppTypeK8s represents a Kubernetes application - AppTypeK8s AppType = "k8s" - // AppTypeDocker represents a Docker application - AppTypeDocker AppType = "docker" -) - -// String returns the string representation of AppType -func (a AppType) String() string { - return string(a) -} - -// DeploymentSummary provides a high-level overview of the deployment plan -type DeploymentSummary struct { - // TotalActions is the total number of actions to be performed - TotalActions int - - // ActionCounts breaks down actions by type - ActionCounts map[ActionType]int - - // EstimatedDuration for the entire deployment - EstimatedDuration time.Duration - - // ResourceSummary describes the resources involved - ResourceSummary ResourceSummary - - // Warnings about potential issues - Warnings []string -} - -// ResourceSummary provides details about resources in the deployment -type ResourceSummary struct { - // AppsToCreate number of apps that will be created - AppsToCreate int - - // AppsToUpdate number of apps that will be updated - AppsToUpdate int - - // InstancesToCreate number of instances that will be created - InstancesToCreate int - - // InstancesToUpdate number of instances that will be updated - InstancesToUpdate int - - // CloudletsAffected number of unique cloudlets involved - CloudletsAffected int - - // RegionsAffected number of unique regions involved - RegionsAffected int -} - -// PlanResult represents the result of a deployment planning operation -type PlanResult struct { - // Plan is the generated deployment plan - Plan *DeploymentPlan - - // Error if planning failed - Error error - - // Warnings encountered during planning - Warnings []string -} - -// ExecutionResult represents the result of executing a deployment plan -type ExecutionResult struct { - // Plan that was executed - Plan *DeploymentPlan - - // Success indicates if the deployment was successful - Success bool - - // CompletedActions lists actions that were successfully completed - CompletedActions []ActionResult - - // FailedActions lists actions that failed - FailedActions []ActionResult - - // Error that caused the deployment to fail (if any) - Error error - - // Duration taken to execute the plan - Duration time.Duration - - // RollbackPerformed indicates if rollback was executed - RollbackPerformed bool - - // RollbackSuccess indicates if rollback was successful - RollbackSuccess bool -} - -// ActionResult represents the result of executing a single action -type ActionResult struct { - // Type of action that was attempted - Type ActionType - - // Target describes what was being acted upon - Target string - - // Success indicates if the action succeeded - Success bool - - // Error if the action failed - Error error - - // Duration taken to complete the action - Duration time.Duration - - // Details provides additional information about the action - Details string -} - -// IsEmpty returns true if the deployment plan has no actions to perform -func (dp *DeploymentPlan) IsEmpty() bool { - if dp.AppAction.Type != ActionNone { - return false - } - - for _, action := range dp.InstanceActions { - if action.Type != ActionNone { - return false - } - } - - return true -} - -// HasErrors returns true if the plan contains any error conditions -func (dp *DeploymentPlan) HasErrors() bool { - // Check for conflicting actions or invalid states - return false // Implementation would check for various error conditions -} - -// GetTargetCloudlets returns a list of unique cloudlets that will be affected -func (dp *DeploymentPlan) GetTargetCloudlets() []string { - cloudletSet := make(map[string]bool) - var cloudlets []string - - for _, action := range dp.InstanceActions { - if action.Type != ActionNone { - key := fmt.Sprintf("%s:%s", action.Target.CloudletOrg, action.Target.CloudletName) - if !cloudletSet[key] { - cloudletSet[key] = true - cloudlets = append(cloudlets, key) - } - } - } - - return cloudlets -} - -// GetTargetRegions returns a list of unique regions that will be affected -func (dp *DeploymentPlan) GetTargetRegions() []string { - regionSet := make(map[string]bool) - var regions []string - - for _, action := range dp.InstanceActions { - if action.Type != ActionNone && !regionSet[action.Target.Region] { - regionSet[action.Target.Region] = true - regions = append(regions, action.Target.Region) - } - } - - return regions -} - -// GenerateSummary creates a human-readable summary of the deployment plan -func (dp *DeploymentPlan) GenerateSummary() string { - if dp.IsEmpty() { - return "No changes required - configuration matches current state" - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName)) - - // App actions - if dp.AppAction.Type != ActionNone { - sb.WriteString(fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name)) - if len(dp.AppAction.Changes) > 0 { - for _, change := range dp.AppAction.Changes { - sb.WriteString(fmt.Sprintf(" - %s\n", change)) - } - } - } - - // Instance actions - createCount := 0 - updateActions := []InstanceAction{} - for _, action := range dp.InstanceActions { - switch action.Type { - case ActionCreate: - createCount++ - case ActionUpdate: - updateActions = append(updateActions, action) - } - } - - if createCount > 0 { - sb.WriteString(fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets()))) - } - - if len(updateActions) > 0 { - sb.WriteString(fmt.Sprintf("- UPDATE %d instance(s)\n", len(updateActions))) - for _, action := range updateActions { - if len(action.Changes) > 0 { - sb.WriteString(fmt.Sprintf(" - Instance '%s':\n", action.InstanceName)) - for _, change := range action.Changes { - sb.WriteString(fmt.Sprintf(" - %s\n", change)) - } - } - } - } - - sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String())) - - return sb.String() -} - -// Validate checks if the deployment plan is valid and safe to execute -func (dp *DeploymentPlan) Validate() error { - if dp.ConfigName == "" { - return fmt.Errorf("deployment plan must have a config name") - } - - // Validate app action - if dp.AppAction.Type != ActionNone && dp.AppAction.Desired == nil { - return fmt.Errorf("app action of type %s must have desired state", dp.AppAction.Type) - } - - // Validate instance actions - for i, action := range dp.InstanceActions { - if action.Type != ActionNone { - if action.Desired == nil { - return fmt.Errorf("instance action %d of type %s must have desired state", i, action.Type) - } - if action.InstanceName == "" { - return fmt.Errorf("instance action %d must have an instance name", i) - } - } - } - - return nil -} - -// Clone creates a deep copy of the deployment plan -func (dp *DeploymentPlan) Clone() *DeploymentPlan { - clone := &DeploymentPlan{ - ConfigName: dp.ConfigName, - Summary: dp.Summary, - TotalActions: dp.TotalActions, - EstimatedDuration: dp.EstimatedDuration, - CreatedAt: dp.CreatedAt, - DryRun: dp.DryRun, - AppAction: dp.AppAction, // Struct copy is sufficient for this use case - } - - // Deep copy instance actions - clone.InstanceActions = make([]InstanceAction, len(dp.InstanceActions)) - copy(clone.InstanceActions, dp.InstanceActions) - - return clone -} - -// convertNetworkRules converts config network rules to EdgeConnect SecurityRules -func convertNetworkRules(network *config.NetworkConfig) []edgeconnect.SecurityRule { - rules := make([]edgeconnect.SecurityRule, len(network.OutboundConnections)) - - for i, conn := range network.OutboundConnections { - rules[i] = edgeconnect.SecurityRule{ - Protocol: conn.Protocol, - PortRangeMin: conn.PortRangeMin, - PortRangeMax: conn.PortRangeMax, - RemoteCIDR: conn.RemoteCIDR, - } - } - - return rules -} diff --git a/internal/domain/app.go b/internal/domain/app.go new file mode 100644 index 0000000..026c122 --- /dev/null +++ b/internal/domain/app.go @@ -0,0 +1,31 @@ +package domain + +// AppKey uniquely identifies an application +type AppKey struct { + Organization string + Name string + Version 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 +} + +// SecurityRule defines network access rules +type SecurityRule struct { + PortRangeMax int + PortRangeMin int + Protocol string + RemoteCIDR string +} diff --git a/internal/domain/app_instance.go b/internal/domain/app_instance.go new file mode 100644 index 0000000..6d2e9bd --- /dev/null +++ b/internal/domain/app_instance.go @@ -0,0 +1,18 @@ +package domain + +// AppInstanceKey uniquely identifies an application instance +type AppInstanceKey struct { + Organization string + Name string + CloudletKey CloudletKey +} + +// AppInstance represents a deployed application instance +type AppInstance struct { + Key AppInstanceKey + AppKey AppKey + Flavor Flavor + State string + PowerState string + Fields []string +} diff --git a/internal/domain/cloudlet.go b/internal/domain/cloudlet.go new file mode 100644 index 0000000..51bc66e --- /dev/null +++ b/internal/domain/cloudlet.go @@ -0,0 +1,26 @@ +package domain + +// CloudletKey uniquely identifies a cloudlet +type CloudletKey struct { + Organization string + Name string +} + +// Cloudlet represents edge infrastructure +type Cloudlet struct { + Key CloudletKey + Location Location + IpSupport string + NumDynamicIps int32 + State string + Flavor Flavor + PhysicalName string + Region string + NotifySrvAddr string +} + +// Location represents geographical coordinates +type Location struct { + Latitude float64 + Longitude float64 +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go new file mode 100644 index 0000000..1194e72 --- /dev/null +++ b/internal/domain/errors.go @@ -0,0 +1,309 @@ +// Package domain contains domain-specific error types for the EdgeConnect client +package domain + +import ( + "errors" + "fmt" + "strings" +) + +// ErrorCode represents different types of domain errors +type ErrorCode int + +const ( + // Resource errors + ErrResourceNotFound ErrorCode = iota + ErrResourceAlreadyExists + ErrResourceConflict + + // Validation errors + ErrValidationFailed + ErrInvalidConfiguration + ErrInvalidInput + + // Business logic errors + ErrQuotaExceeded + ErrInsufficientPermissions + ErrOperationNotAllowed + + // Infrastructure errors + ErrNetworkError + ErrAuthenticationFailed + ErrServiceUnavailable + ErrTimeout + + // Internal errors + ErrInternalError + ErrUnknownError +) + +// String returns a human-readable string representation of the error code +func (e ErrorCode) String() string { + switch e { + case ErrResourceNotFound: + return "RESOURCE_NOT_FOUND" + case ErrResourceAlreadyExists: + return "RESOURCE_ALREADY_EXISTS" + case ErrResourceConflict: + return "RESOURCE_CONFLICT" + case ErrValidationFailed: + return "VALIDATION_FAILED" + case ErrInvalidConfiguration: + return "INVALID_CONFIGURATION" + case ErrInvalidInput: + return "INVALID_INPUT" + case ErrQuotaExceeded: + return "QUOTA_EXCEEDED" + case ErrInsufficientPermissions: + return "INSUFFICIENT_PERMISSIONS" + case ErrOperationNotAllowed: + return "OPERATION_NOT_ALLOWED" + case ErrNetworkError: + return "NETWORK_ERROR" + case ErrAuthenticationFailed: + return "AUTHENTICATION_FAILED" + case ErrServiceUnavailable: + return "SERVICE_UNAVAILABLE" + case ErrTimeout: + return "TIMEOUT" + case ErrInternalError: + return "INTERNAL_ERROR" + case ErrUnknownError: + return "UNKNOWN_ERROR" + default: + return "UNDEFINED_ERROR" + } +} + +// DomainError represents a domain-specific error with detailed context +type DomainError struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` + Cause error `json:"-"` + Context map[string]interface{} `json:"context,omitempty"` + Resource *ResourceIdentifier `json:"resource,omitempty"` + Operation string `json:"operation,omitempty"` + Retryable bool `json:"retryable"` +} + +// ResourceIdentifier provides context about the resource involved in the error +type ResourceIdentifier struct { + Type string `json:"type"` + Name string `json:"name"` + Organization string `json:"organization,omitempty"` + Region string `json:"region,omitempty"` + Version string `json:"version,omitempty"` +} + +// Error implements the error interface +func (e *DomainError) Error() string { + var parts []string + + if e.Operation != "" { + parts = append(parts, fmt.Sprintf("operation %s failed", e.Operation)) + } + + if e.Resource != nil { + parts = append(parts, fmt.Sprintf("resource %s", e.resourceString())) + } + + parts = append(parts, e.Message) + + if e.Details != "" { + parts = append(parts, e.Details) + } + + result := strings.Join(parts, ": ") + + if e.Cause != nil { + result = fmt.Sprintf("%s (caused by: %v)", result, e.Cause) + } + + return result +} + +// Unwrap returns the underlying cause for error wrapping +func (e *DomainError) Unwrap() error { + return e.Cause +} + +// Is checks if the error matches a specific error code +func (e *DomainError) Is(target error) bool { + if de, ok := target.(*DomainError); ok { + return e.Code == de.Code + } + return false +} + +// IsRetryable indicates whether the operation should be retried +func (e *DomainError) IsRetryable() bool { + return e.Retryable +} + +// WithContext adds context information to the error +func (e *DomainError) WithContext(key string, value interface{}) *DomainError { + if e.Context == nil { + e.Context = make(map[string]interface{}) + } + e.Context[key] = value + return e +} + +// WithDetails adds additional details to the error +func (e *DomainError) WithDetails(details string) *DomainError { + e.Details = details + return e +} + +func (e *DomainError) resourceString() string { + if e.Resource == nil { + return "" + } + + parts := []string{e.Resource.Type} + + if e.Resource.Organization != "" && e.Resource.Name != "" { + parts = append(parts, fmt.Sprintf("%s/%s", e.Resource.Organization, e.Resource.Name)) + } else if e.Resource.Name != "" { + parts = append(parts, e.Resource.Name) + } + + if e.Resource.Version != "" { + parts = append(parts, fmt.Sprintf("version %s", e.Resource.Version)) + } + + if e.Resource.Region != "" { + parts = append(parts, fmt.Sprintf("in region %s", e.Resource.Region)) + } + + return strings.Join(parts, " ") +} + +// Error creation helpers + +// NewDomainError creates a new domain error with the specified code and message +func NewDomainError(code ErrorCode, message string) *DomainError { + return &DomainError{ + Code: code, + Message: message, + Retryable: isRetryableByDefault(code), + } +} + +// NewDomainErrorWithCause creates a new domain error with an underlying cause +func NewDomainErrorWithCause(code ErrorCode, message string, cause error) *DomainError { + return &DomainError{ + Code: code, + Message: message, + Cause: cause, + Retryable: isRetryableByDefault(code), + } +} + +// NewResourceError creates a domain error for resource-related operations +func NewResourceError(code ErrorCode, operation string, resource *ResourceIdentifier, message string) *DomainError { + return &DomainError{ + Code: code, + Message: message, + Operation: operation, + Resource: resource, + Retryable: isRetryableByDefault(code), + } +} + +func isRetryableByDefault(code ErrorCode) bool { + switch code { + case ErrNetworkError, ErrServiceUnavailable, ErrTimeout, ErrInternalError: + return true + default: + return false + } +} + +// Predefined errors for common scenarios + +var ( + // Resource errors + ErrAppNotFound = NewDomainError(ErrResourceNotFound, "application not found") + ErrAppExists = NewDomainError(ErrResourceAlreadyExists, "application already exists") + ErrInstanceNotFound = NewDomainError(ErrResourceNotFound, "app instance not found") + ErrInstanceExists = NewDomainError(ErrResourceAlreadyExists, "app instance already exists") + ErrCloudletNotFound = NewDomainError(ErrResourceNotFound, "cloudlet not found") + + // Validation errors + ErrInvalidAppKey = NewDomainError(ErrValidationFailed, "invalid application key") + ErrInvalidInstanceKey = NewDomainError(ErrValidationFailed, "invalid app instance key") + ErrInvalidCloudletKey = NewDomainError(ErrValidationFailed, "invalid cloudlet key") + ErrMissingRegion = NewDomainError(ErrValidationFailed, "region is required") + + // Business logic errors + ErrDeploymentFailed = NewDomainError(ErrOperationNotAllowed, "deployment failed") + ErrRollbackFailed = NewDomainError(ErrOperationNotAllowed, "rollback failed") + ErrPlanningFailed = NewDomainError(ErrOperationNotAllowed, "deployment planning failed") +) + +// Helper functions for creating specific error scenarios + +// NewAppError creates an error related to application operations +func NewAppError(code ErrorCode, operation string, appKey AppKey, region string, message string) *DomainError { + resource := &ResourceIdentifier{ + Type: "app", + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + Region: region, + } + return NewResourceError(code, operation, resource, message) +} + +// NewInstanceError creates an error related to app instance operations +func NewInstanceError(code ErrorCode, operation string, instanceKey AppInstanceKey, region string, message string) *DomainError { + resource := &ResourceIdentifier{ + Type: "app-instance", + Organization: instanceKey.Organization, + Name: instanceKey.Name, + Region: region, + } + return NewResourceError(code, operation, resource, message) +} + +// NewCloudletError creates an error related to cloudlet operations +func NewCloudletError(code ErrorCode, operation string, cloudletKey CloudletKey, region string, message string) *DomainError { + resource := &ResourceIdentifier{ + Type: "cloudlet", + Organization: cloudletKey.Organization, + Name: cloudletKey.Name, + Region: region, + } + return NewResourceError(code, operation, resource, message) +} + +// Error checking utilities + +// IsNotFoundError checks if an error indicates a resource was not found +func IsNotFoundError(err error) bool { + var de *DomainError + return errors.As(err, &de) && de.Code == ErrResourceNotFound +} + +// IsValidationError checks if an error is a validation error +func IsValidationError(err error) bool { + var de *DomainError + return errors.As(err, &de) && (de.Code == ErrValidationFailed || de.Code == ErrInvalidInput || de.Code == ErrInvalidConfiguration) +} + +// IsRetryableError checks if an error is retryable +func IsRetryableError(err error) bool { + var de *DomainError + if errors.As(err, &de) { + return de.IsRetryable() + } + return false +} + +// IsAuthenticationError checks if an error is authentication-related +func IsAuthenticationError(err error) bool { + var de *DomainError + return errors.As(err, &de) && de.Code == ErrAuthenticationFailed +} \ No newline at end of file diff --git a/internal/domain/errors_test.go b/internal/domain/errors_test.go new file mode 100644 index 0000000..fcc6f6d --- /dev/null +++ b/internal/domain/errors_test.go @@ -0,0 +1,207 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestDomainError_Creation(t *testing.T) { + tests := []struct { + name string + code ErrorCode + message string + expected string + }{ + { + name: "simple error", + code: ErrResourceNotFound, + message: "test resource not found", + expected: "test resource not found", + }, + { + name: "validation error", + code: ErrValidationFailed, + message: "invalid input", + expected: "invalid input", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := NewDomainError(tt.code, tt.message) + if err.Error() != tt.expected { + t.Errorf("Expected error message %q, got %q", tt.expected, err.Error()) + } + if err.Code != tt.code { + t.Errorf("Expected error code %v, got %v", tt.code, err.Code) + } + }) + } +} + +func TestDomainError_WithContext(t *testing.T) { + err := NewDomainError(ErrResourceNotFound, "test error") + err = err.WithContext("user_id", "123") + err = err.WithContext("operation", "create") + + if len(err.Context) != 2 { + t.Errorf("Expected 2 context items, got %d", len(err.Context)) + } + + if err.Context["user_id"] != "123" { + t.Errorf("Expected user_id to be '123', got %v", err.Context["user_id"]) + } +} + +func TestDomainError_WithDetails(t *testing.T) { + err := NewDomainError(ErrValidationFailed, "validation failed") + err = err.WithDetails("name field is required") + + expectedError := "validation failed: name field is required" + if err.Error() != expectedError { + t.Errorf("Expected error %q, got %q", expectedError, err.Error()) + } +} + +func TestDomainError_WithCause(t *testing.T) { + cause := errors.New("network timeout") + err := NewDomainErrorWithCause(ErrNetworkError, "operation failed", cause) + + if err.Cause != cause { + t.Error("Expected cause to be preserved") + } + + if !errors.Is(err, cause) { + t.Error("Expected error to wrap the cause") + } +} + +func TestAppError_Creation(t *testing.T) { + appKey := AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + } + + err := NewAppError(ErrResourceNotFound, "ShowApp", appKey, "US", "not found") + + expected := "operation ShowApp failed: resource app test-org/test-app version 1.0.0 in region US: not found" + if err.Error() != expected { + t.Errorf("Expected error %q, got %q", expected, err.Error()) + } + + if err.Resource.Type != "app" { + t.Errorf("Expected resource type 'app', got %q", err.Resource.Type) + } +} + +func TestInstanceError_Creation(t *testing.T) { + instanceKey := AppInstanceKey{ + Organization: "test-org", + Name: "test-instance", + CloudletKey: CloudletKey{ + Organization: "cloudlet-org", + Name: "cloudlet-name", + }, + } + + err := NewInstanceError(ErrResourceNotFound, "ShowAppInstance", instanceKey, "US", "not found") + + if err.Resource.Type != "app-instance" { + t.Errorf("Expected resource type 'app-instance', got %q", err.Resource.Type) + } + + if err.Operation != "ShowAppInstance" { + t.Errorf("Expected operation 'ShowAppInstance', got %q", err.Operation) + } +} + +func TestErrorChecking_Functions(t *testing.T) { + tests := []struct { + name string + err error + checkFn func(error) bool + expected bool + }{ + { + name: "IsNotFoundError with not found error", + err: NewDomainError(ErrResourceNotFound, "not found"), + checkFn: IsNotFoundError, + expected: true, + }, + { + name: "IsNotFoundError with validation error", + err: NewDomainError(ErrValidationFailed, "invalid"), + checkFn: IsNotFoundError, + expected: false, + }, + { + name: "IsValidationError with validation error", + err: NewDomainError(ErrValidationFailed, "invalid"), + checkFn: IsValidationError, + expected: true, + }, + { + name: "IsRetryableError with network error", + err: NewDomainError(ErrNetworkError, "connection failed"), + checkFn: IsRetryableError, + expected: true, + }, + { + name: "IsRetryableError with validation error", + err: NewDomainError(ErrValidationFailed, "invalid"), + checkFn: IsRetryableError, + expected: false, + }, + { + name: "IsAuthenticationError with auth error", + err: NewDomainError(ErrAuthenticationFailed, "unauthorized"), + checkFn: IsAuthenticationError, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.checkFn(tt.err) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestErrorCode_String(t *testing.T) { + tests := []struct { + code ErrorCode + expected string + }{ + {ErrResourceNotFound, "RESOURCE_NOT_FOUND"}, + {ErrValidationFailed, "VALIDATION_FAILED"}, + {ErrNetworkError, "NETWORK_ERROR"}, + {ErrUnknownError, "UNKNOWN_ERROR"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if tt.code.String() != tt.expected { + t.Errorf("Expected %q, got %q", tt.expected, tt.code.String()) + } + }) + } +} + +func TestPredefinedErrors(t *testing.T) { + // Test that predefined errors have correct codes + if ErrAppNotFound.Code != ErrResourceNotFound { + t.Error("ErrAppNotFound should have ErrResourceNotFound code") + } + + if ErrInvalidAppKey.Code != ErrValidationFailed { + t.Error("ErrInvalidAppKey should have ErrValidationFailed code") + } + + if ErrDeploymentFailed.Code != ErrOperationNotAllowed { + t.Error("ErrDeploymentFailed should have ErrOperationNotAllowed code") + } +} \ No newline at end of file diff --git a/internal/domain/flavor.go b/internal/domain/flavor.go new file mode 100644 index 0000000..e11b3ed --- /dev/null +++ b/internal/domain/flavor.go @@ -0,0 +1,6 @@ +package domain + +// Flavor defines resource allocation for instances +type Flavor struct { + Name string +} diff --git a/internal/domain/organization.go b/internal/domain/organization.go new file mode 100644 index 0000000..34b2c5b --- /dev/null +++ b/internal/domain/organization.go @@ -0,0 +1,9 @@ +package domain + +// Organization represents the core business object for an organization. +// It contains identifying information such as name, address, and phone number. +type Organization struct { + Name string `json:"name"` + Address string `json:"address"` + Phone string `json:"phone"` +} diff --git a/internal/config/config_test.go b/internal/infrastructure/config/config_test.go similarity index 100% rename from internal/config/config_test.go rename to internal/infrastructure/config/config_test.go diff --git a/internal/config/example_test.go b/internal/infrastructure/config/example_test.go similarity index 59% rename from internal/config/example_test.go rename to internal/infrastructure/config/example_test.go index dfa3840..a6f3a09 100644 --- a/internal/config/example_test.go +++ b/internal/infrastructure/config/example_test.go @@ -3,7 +3,6 @@ package config import ( - "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -11,56 +10,47 @@ import ( ) func TestParseExampleConfig(t *testing.T) { + // The base path is relative to the location of this test file parser := NewParser() - - // Parse the actual example file (now that we've created the manifest file) - examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig.yaml") - config, parsedManifest, err := parser.ParseFile(examplePath) - - // This should now succeed with full validation + cfg, _, err := parser.ParseFile("../../../sdk/examples/comprehensive/EdgeConnectConfig.yaml") require.NoError(t, err) - require.NotNil(t, config) - require.NotEmpty(t, parsedManifest) + require.NotNil(t, cfg) - // Validate the parsed structure - assert.Equal(t, "edgeconnect-deployment", config.Kind) - assert.Equal(t, "edge-app-demo", config.Metadata.Name) - - // Check k8s app configuration - require.NotNil(t, config.Spec.K8sApp) - assert.Equal(t, "1.0.0", config.Metadata.AppVersion) - // Note: ManifestFile path should be resolved to absolute path - assert.Contains(t, config.Spec.K8sApp.ManifestFile, "k8s-deployment.yaml") + // Basic validation + assert.Equal(t, "edgeconnect-deployment", cfg.Kind) + assert.Equal(t, "edge-app-demo", cfg.Metadata.Name) + assert.NotNil(t, cfg.Spec.K8sApp) + assert.NotEmpty(t, cfg.Spec.K8sApp.ManifestFile) // Check infrastructure template - require.Len(t, config.Spec.InfraTemplate, 1) - infra := config.Spec.InfraTemplate[0] + require.Len(t, cfg.Spec.InfraTemplate, 1) + infra := cfg.Spec.InfraTemplate[0] assert.Equal(t, "EU", infra.Region) assert.Equal(t, "TelekomOP", infra.CloudletOrg) assert.Equal(t, "Munich", infra.CloudletName) assert.Equal(t, "EU.small", infra.FlavorName) // Check network configuration - require.NotNil(t, config.Spec.Network) - require.Len(t, config.Spec.Network.OutboundConnections, 2) + require.NotNil(t, cfg.Spec.Network) + require.Len(t, cfg.Spec.Network.OutboundConnections, 2) - conn1 := config.Spec.Network.OutboundConnections[0] + conn1 := cfg.Spec.Network.OutboundConnections[0] assert.Equal(t, "tcp", conn1.Protocol) assert.Equal(t, 80, conn1.PortRangeMin) assert.Equal(t, 80, conn1.PortRangeMax) assert.Equal(t, "0.0.0.0/0", conn1.RemoteCIDR) - conn2 := config.Spec.Network.OutboundConnections[1] + conn2 := cfg.Spec.Network.OutboundConnections[1] assert.Equal(t, "tcp", conn2.Protocol) assert.Equal(t, 443, conn2.PortRangeMin) assert.Equal(t, 443, conn2.PortRangeMax) assert.Equal(t, "0.0.0.0/0", conn2.RemoteCIDR) // Test utility methods - assert.Equal(t, "edge-app-demo", config.Metadata.Name) - assert.Contains(t, config.Spec.GetManifestFile(), "k8s-deployment.yaml") - assert.True(t, config.Spec.IsK8sApp()) - assert.False(t, config.Spec.IsDockerApp()) + assert.Equal(t, "edge-app-demo", cfg.Metadata.Name) + assert.Contains(t, cfg.Spec.GetManifestFile(), "k8s-deployment.yaml") + assert.True(t, cfg.Spec.IsK8sApp()) + assert.False(t, cfg.Spec.IsDockerApp()) } func TestValidateExampleStructure(t *testing.T) { @@ -70,13 +60,13 @@ func TestValidateExampleStructure(t *testing.T) { config := &EdgeConnectConfig{ Kind: "edgeconnect-deployment", Metadata: Metadata{ - Name: "edge-app-demo", - AppVersion: "1.0.0", + Name: "edge-app-demo", + AppVersion: "1.0.0", Organization: "edp2", }, Spec: Spec{ DockerApp: &DockerApp{ // Use DockerApp to avoid manifest file validation - Image: "nginx:latest", + Image: "nginx:latest", }, InfraTemplate: []InfraTemplate{ { diff --git a/internal/config/parser.go b/internal/infrastructure/config/parser.go similarity index 100% rename from internal/config/parser.go rename to internal/infrastructure/config/parser.go diff --git a/internal/config/parser_test.go b/internal/infrastructure/config/parser_test.go similarity index 100% rename from internal/config/parser_test.go rename to internal/infrastructure/config/parser_test.go diff --git a/internal/config/types.go b/internal/infrastructure/config/types.go similarity index 100% rename from internal/config/types.go rename to internal/infrastructure/config/types.go diff --git a/sdk/edgeconnect/auth.go b/internal/infrastructure/edgeconnect_client/auth.go similarity index 95% rename from sdk/edgeconnect/auth.go rename to internal/infrastructure/edgeconnect_client/auth.go index eab24b9..b321ec7 100644 --- a/sdk/edgeconnect/auth.go +++ b/internal/infrastructure/edgeconnect_client/auth.go @@ -1,7 +1,7 @@ // ABOUTME: Authentication providers for EdgeXR Master Controller API // ABOUTME: Supports Bearer token authentication with pluggable provider interface -package edgeconnect +package edgeconnect_client import ( "bytes" @@ -10,6 +10,7 @@ import ( "fmt" "io" "net/http" + "os" "strings" "sync" "time" @@ -138,7 +139,12 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e if err != nil { return "", err } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + // Can't use c.logf here since this is in auth provider + fmt.Fprintf(os.Stderr, "Warning: failed to close auth response body: %v\n", err) + } + }() // Read response body - same as existing implementation body, err := io.ReadAll(resp.Body) diff --git a/internal/infrastructure/edgeconnect_client/client.go b/internal/infrastructure/edgeconnect_client/client.go new file mode 100644 index 0000000..ef425e1 --- /dev/null +++ b/internal/infrastructure/edgeconnect_client/client.go @@ -0,0 +1,353 @@ +// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth +// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations + +package edgeconnect_client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/transport" +) + +// Client represents the EdgeXR Master Controller SDK client +type Client struct { + BaseURL string + HTTPClient *http.Client + AuthProvider AuthProvider + RetryOpts transport.RetryOptions + Logger Logger +} + +// Logger interface for optional logging +type Logger interface { + Printf(format string, v ...interface{}) +} + +// DefaultRetryOptions returns sensible default retry configuration +func DefaultRetryOptions() transport.RetryOptions { + return transport.RetryOptions{ + MaxRetries: 3, + InitialDelay: 1 * time.Second, + MaxDelay: 30 * time.Second, + Multiplier: 2.0, + RetryableHTTPStatusCodes: []int{ + http.StatusRequestTimeout, + http.StatusTooManyRequests, + http.StatusInternalServerError, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout, + }, + } +} + +// Option represents a configuration option for the client +type Option func(*Client) + +// WithHTTPClient sets a custom HTTP client +func WithHTTPClient(client *http.Client) Option { + return func(c *Client) { + c.HTTPClient = client + } +} + +// WithAuthProvider sets the authentication provider +func WithAuthProvider(auth AuthProvider) Option { + return func(c *Client) { + c.AuthProvider = auth + } +} + +// WithRetryOptions sets retry configuration +func WithRetryOptions(opts transport.RetryOptions) Option { + return func(c *Client) { + c.RetryOpts = opts + } +} + +// WithLogger sets a logger for debugging +func WithLogger(logger Logger) Option { + return func(c *Client) { + c.Logger = logger + } +} + +// NewClient creates a new EdgeXR SDK client +func NewClient(baseURL string, options ...Option) *Client { + client := &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + AuthProvider: NewNoAuthProvider(), + RetryOpts: DefaultRetryOptions(), + } + + for _, opt := range options { + opt(client) + } + + return client +} + +// NewClientWithCredentials creates a new EdgeXR SDK client with username/password authentication +func NewClientWithCredentials(baseURL, username, password string, options ...Option) *Client { + // Pass the HTTP client from options to the provider if it exists + tempClient := &Client{ + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + } + for _, opt := range options { + opt(tempClient) + } + + client := &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTPClient: tempClient.HTTPClient, + AuthProvider: NewUsernamePasswordProvider(baseURL, username, password, tempClient.HTTPClient), + RetryOpts: DefaultRetryOptions(), + } + + // Apply other options again, which might override the HTTPClient, but that's fine. + for _, opt := range options { + opt(client) + } + + return client +} + +// Logf logs a message if a logger is configured +func (c *Client) Logf(format string, v ...interface{}) { + if c.Logger != nil { + c.Logger.Printf(format, v...) + } +} + +// Call performs a generic API call +func (c *Client) Call(ctx context.Context, method, path string, body, result interface{}) (*http.Response, error) { + t := c.getTransport() + url := c.BaseURL + path + + resp, err := t.Call(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf("API call to %s %s failed: %w", method, path, err) + } + + // If result is nil, the caller doesn't expect a body to be parsed. + // They are responsible for closing the response body. + if result == nil { + return resp, nil + } + + // If result is not nil, we handle the body and closing it. + defer func() { + if err := resp.Body.Close(); err != nil { + c.Logf("Failed to close response body: %v", err) + } + }() + + if resp.StatusCode >= 400 { + return resp, c.handleErrorResponse(resp, fmt.Sprintf("%s %s", method, path)) + } + + // Handle different response types + switch v := result.(type) { + case *string: + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return resp, fmt.Errorf("failed to read response body: %w", err) + } + *v = string(bodyBytes) + case io.Writer: + _, err := io.Copy(v, resp.Body) + if err != nil { + return resp, fmt.Errorf("failed to write response body: %w", err) + } + default: + // Default to JSON decoding + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return resp, fmt.Errorf("failed to decode JSON response: %w", err) + } + } + + return resp, nil +} + +// getTransport creates an HTTP transport with current client settings +func (c *Client) getTransport() *transport.Transport { + return transport.NewTransport( + c.RetryOpts, + c.AuthProvider, + c.Logger, + ) +} + +// handleErrorResponse creates an appropriate error from HTTP error response +func (c *Client) handleErrorResponse(resp *http.Response, operation string) error { + messages := []string{ + fmt.Sprintf("%s failed with status %d", operation, resp.StatusCode), + } + + bodyBytes, _ := io.ReadAll(resp.Body) // Read body, ignore error as it might be empty + if len(bodyBytes) > 0 { + messages = append(messages, string(bodyBytes)) + } + + return &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + Body: bodyBytes, + } +} + +// parseStreamingResponse parses the EdgeXR streaming JSON response format +func (c *Client) ParseStreamingResponse(resp *http.Response, result interface{}) error { + var responses []Response[App] + + parseErr := transport.ParseJSONLines(resp.Body, func(line []byte) error { + var response Response[App] + if err := json.Unmarshal(line, &response); err != nil { + return err + } + responses = append(responses, response) + return nil + }) + + if parseErr != nil { + return parseErr + } + + // Extract data from responses + var apps []App + var messages []string + + for _, response := range responses { + if response.HasData() { + apps = append(apps, response.Data) + } + if response.IsMessage() { + messages = append(messages, response.Data.GetMessage()) + } + } + + // If we have error messages, return them + if len(messages) > 0 { + return &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + } + } + + // Set result based on type + switch v := result.(type) { + case *[]App: + *v = apps + default: + return fmt.Errorf("unsupported result type: %T", result) + } + + return nil +} + +// Helper functions for parsing streaming responses + +// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances +func (c *Client) ParseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error { + var responses []Response[AppInstance] + + parseErr := transport.ParseJSONLines(resp.Body, func(line []byte) error { + var response Response[AppInstance] + if err := json.Unmarshal(line, &response); err != nil { + return err + } + responses = append(responses, response) + return nil + }) + + if parseErr != nil { + return parseErr + } + + // Extract data from responses + var appInstances []AppInstance + var messages []string + + for _, response := range responses { + if response.HasData() { + appInstances = append(appInstances, response.Data) + } + if response.IsMessage() { + messages = append(messages, response.Data.GetMessage()) + } + } + + // If we have error messages, return them + if len(messages) > 0 { + return &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + } + } + + // Set result based on type + switch v := result.(type) { + case *[]AppInstance: + *v = appInstances + default: + return fmt.Errorf("unsupported result type: %T", result) + } + + return nil +} + +// parseStreamingCloudletResponse parses the EdgeXR streaming JSON response format for cloudlets +func (c *Client) ParseStreamingCloudletResponse(resp *http.Response, result interface{}) error { + var responses []Response[Cloudlet] + + parseErr := transport.ParseJSONLines(resp.Body, func(line []byte) error { + var response Response[Cloudlet] + if err := json.Unmarshal(line, &response); err != nil { + return err + } + responses = append(responses, response) + return nil + }) + + if parseErr != nil { + return parseErr + } + + // Extract data from responses + var cloudlets []Cloudlet + var messages []string + + for _, response := range responses { + if response.HasData() { + cloudlets = append(cloudlets, response.Data) + } + if response.IsMessage() { + messages = append(messages, response.Data.GetMessage()) + } + } + + // If we have error messages, return them + if len(messages) > 0 { + return &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + } + } + + // Set result based on type + switch v := result.(type) { + case *[]Cloudlet: + *v = cloudlets + default: + return fmt.Errorf("unsupported result type: %T", result) + } + + return nil +} diff --git a/sdk/edgeconnect/types.go b/internal/infrastructure/edgeconnect_client/types.go similarity index 99% rename from sdk/edgeconnect/types.go rename to internal/infrastructure/edgeconnect_client/types.go index 6f82d51..a35844c 100644 --- a/sdk/edgeconnect/types.go +++ b/internal/infrastructure/edgeconnect_client/types.go @@ -1,12 +1,14 @@ // ABOUTME: Core type definitions for EdgeXR Master Controller SDK // ABOUTME: These types are based on the swagger API specification and existing client patterns -package edgeconnect +package edgeconnect_client import ( "encoding/json" "fmt" "time" + + ) // App field constants for partial updates (based on EdgeXR API specification) @@ -358,4 +360,4 @@ type CloudletResourceUsage struct { CloudletKey CloudletKey `json:"cloudlet_key"` Region string `json:"region"` Usage map[string]interface{} `json:"usage"` -} +} \ No newline at end of file diff --git a/internal/infrastructure/transport/parser.go b/internal/infrastructure/transport/parser.go new file mode 100644 index 0000000..0779ee0 --- /dev/null +++ b/internal/infrastructure/transport/parser.go @@ -0,0 +1,28 @@ +package transport + +import ( + "encoding/json" + "fmt" + "io" +) + +// ParseJSONLines parses streaming JSON response line by line +func ParseJSONLines(body io.Reader, callback func([]byte) error) error { + decoder := json.NewDecoder(body) + + for { + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("failed to decode JSON line: %w", err) + } + + if err := callback(raw); err != nil { + return err + } + } + + return nil +} diff --git a/internal/infrastructure/transport/transport.go b/internal/infrastructure/transport/transport.go new file mode 100644 index 0000000..83ddaee --- /dev/null +++ b/internal/infrastructure/transport/transport.go @@ -0,0 +1,137 @@ +package transport + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "time" +) + +// AuthProvider defines the interface for attaching authentication to requests. +type AuthProvider interface { + Attach(ctx context.Context, req *http.Request) error +} + +// Logger defines the interface for logging. +type Logger interface { + Printf(format string, v ...interface{}) +} + +// RetryOptions configures the retry behavior for API calls. +type RetryOptions struct { + MaxRetries int + InitialDelay time.Duration + MaxDelay time.Duration + Multiplier float64 + RetryableHTTPStatusCodes []int +} + +// Transport handles the lifecycle of an HTTP request, including authentication and retries. +type Transport struct { + retryOptions RetryOptions + authProvider AuthProvider + logger Logger + httpClient *http.Client +} + +// NewTransport creates a new Transport. +func NewTransport(retryOptions RetryOptions, authProvider AuthProvider, logger Logger) *Transport { + return &Transport{ + retryOptions: retryOptions, + authProvider: authProvider, + logger: logger, + httpClient: &http.Client{}, // Use a default client + } +} + +// Call executes an HTTP request with the configured transport options. +func (t *Transport) Call(ctx context.Context, method, url string, body interface{}) (*http.Response, error) { + var reqBody io.Reader + if body != nil { + // Marshal body to JSON + jsonData, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewBuffer(jsonData) + } + + var resp *http.Response + var err error + + for i := 0; i <= t.retryOptions.MaxRetries; i++ { + // Create a new request for each attempt + req, reqErr := http.NewRequestWithContext(ctx, method, url, reqBody) + if reqErr != nil { + return nil, fmt.Errorf("failed to create request: %w", reqErr) + } + req.Header.Set("Content-Type", "application/json") + + // Attach authentication + if authErr := t.authProvider.Attach(ctx, req); authErr != nil { + return nil, fmt.Errorf("failed to attach authentication: %w", authErr) + } + + // Perform the request + resp, err = t.httpClient.Do(req) + if err != nil { + t.logf("Request failed (attempt %d): %v", i+1, err) + // Decide if we should retry based on the error (e.g., network errors) + if i < t.retryOptions.MaxRetries { + time.Sleep(t.calculateBackoff(i)) + continue + } + return nil, fmt.Errorf("request failed after %d attempts: %w", t.retryOptions.MaxRetries+1, err) + } + + // Check if we should retry based on status code + if t.isRetryable(resp.StatusCode) && i < t.retryOptions.MaxRetries { + t.logf("Request returned retryable status %d (attempt %d)", resp.StatusCode, i+1) + // We need to close the body before retrying + if resp.Body != nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + } + time.Sleep(t.calculateBackoff(i)) + continue + } + + // If not retryable, break the loop + break + } + + return resp, err +} + +// isRetryable checks if an HTTP status code is in the list of retryable codes. +func (t *Transport) isRetryable(statusCode int) bool { + for _, code := range t.retryOptions.RetryableHTTPStatusCodes { + if statusCode == code { + return true + } + } + return false +} + +// calculateBackoff computes the delay for the next retry attempt. +func (t *Transport) calculateBackoff(attempt int) time.Duration { + if attempt == 0 { + return t.retryOptions.InitialDelay + } + delay := float64(t.retryOptions.InitialDelay) * math.Pow(t.retryOptions.Multiplier, float64(attempt)) + if delay > float64(t.retryOptions.MaxDelay) { + return t.retryOptions.MaxDelay + } + return time.Duration(delay) +} + +// logf logs a message if a logger is configured. +func (t *Transport) logf(format string, v ...interface{}) { + if t.logger != nil { + t.logger.Printf(format, v...) + } +} diff --git a/internal/ports/driven/app_repository.go b/internal/ports/driven/app_repository.go new file mode 100644 index 0000000..6c3d931 --- /dev/null +++ b/internal/ports/driven/app_repository.go @@ -0,0 +1,14 @@ +package driven + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" +) + +type AppRepository interface { + CreateApp(ctx context.Context, region string, app *domain.App) error + ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) + ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) + DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error + UpdateApp(ctx context.Context, region string, app *domain.App) error +} diff --git a/internal/ports/driven/cloudlet_repository.go b/internal/ports/driven/cloudlet_repository.go new file mode 100644 index 0000000..c51c751 --- /dev/null +++ b/internal/ports/driven/cloudlet_repository.go @@ -0,0 +1,13 @@ +package driven + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" +) + +type CloudletRepository interface { + CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error + ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) + ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) + DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error +} diff --git a/internal/ports/driven/instance_repository.go b/internal/ports/driven/instance_repository.go new file mode 100644 index 0000000..94af8b9 --- /dev/null +++ b/internal/ports/driven/instance_repository.go @@ -0,0 +1,15 @@ +package driven + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" +) + +type AppInstanceRepository interface { + CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error + ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) + ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) + DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error + UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error + RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error +} diff --git a/internal/ports/driven/organization_repository.go b/internal/ports/driven/organization_repository.go new file mode 100644 index 0000000..599309e --- /dev/null +++ b/internal/ports/driven/organization_repository.go @@ -0,0 +1,20 @@ +package driven + +import ( + "context" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" +) + +// OrganizationRepository defines the port for interacting with organization data storage. +// This interface provides a technology-agnostic way for the core application to manage organizations. +type OrganizationRepository interface { + // CreateOrganization persists a new organization. + CreateOrganization(ctx context.Context, org *domain.Organization) error + // ShowOrganization retrieves a single organization by its name. + ShowOrganization(ctx context.Context, name string) (*domain.Organization, error) + // UpdateOrganization updates an existing organization. + UpdateOrganization(ctx context.Context, org *domain.Organization) error + // DeleteOrganization removes an organization by its name. + DeleteOrganization(ctx context.Context, name string) error +} diff --git a/internal/ports/driving/app_service.go b/internal/ports/driving/app_service.go new file mode 100644 index 0000000..ef47c54 --- /dev/null +++ b/internal/ports/driving/app_service.go @@ -0,0 +1,14 @@ +package driving + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" +) + +type AppService interface { + CreateApp(ctx context.Context, region string, app *domain.App) error + ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) + ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) + DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error + UpdateApp(ctx context.Context, region string, app *domain.App) error +} diff --git a/internal/ports/driving/cloudlet_service.go b/internal/ports/driving/cloudlet_service.go new file mode 100644 index 0000000..6b3359f --- /dev/null +++ b/internal/ports/driving/cloudlet_service.go @@ -0,0 +1,13 @@ +package driving + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" +) + +type CloudletService interface { + CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error + ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) + ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) + DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error +} diff --git a/internal/ports/driving/instance_service.go b/internal/ports/driving/instance_service.go new file mode 100644 index 0000000..5f4bb23 --- /dev/null +++ b/internal/ports/driving/instance_service.go @@ -0,0 +1,15 @@ +package driving + +import ( + "context" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" +) + +type AppInstanceService interface { + CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error + ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) + ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) + DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error + UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error + RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error +} diff --git a/internal/ports/driving/organization_service.go b/internal/ports/driving/organization_service.go new file mode 100644 index 0000000..a3d2c9f --- /dev/null +++ b/internal/ports/driving/organization_service.go @@ -0,0 +1,16 @@ +package driving + +import ( + "context" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" +) + +// OrganizationService defines the driving port for managing organizations. +// This is the primary interface for interacting with the application's organization logic. +type OrganizationService interface { + Create(ctx context.Context, org *domain.Organization) error + Get(ctx context.Context, name string) (*domain.Organization, error) + Update(ctx context.Context, org *domain.Organization) error + Delete(ctx context.Context, name string) error +} diff --git a/main.go b/main.go deleted file mode 100644 index 9bc902d..0000000 --- a/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "edp.buildth.ing/DevFW-CICD/edge-connect-client/cmd" - -func main() { - cmd.Execute() -} diff --git a/sdk/README.md b/sdk/README.md index 0f16b12..6f6a4de 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -125,14 +125,14 @@ client := client.NewClient(baseURL, ### Simple App Deployment ```bash # Run basic example -EDGEXR_USERNAME=user EDGEXR_PASSWORD=pass go run sdk/examples/deploy_app.go +EDGE_CONNECT_USERNAME=user EDGE_CONNECT_PASSWORD=pass go run sdk/examples/deploy_app.go ``` ### Comprehensive Workflow ```bash # Run full workflow demonstration cd sdk/examples/comprehensive -EDGEXR_USERNAME=user EDGEXR_PASSWORD=pass go run main.go +EDGE_CONNECT_USERNAME=user EDGE_CONNECT_PASSWORD=pass go run main.go ``` ## Authentication Methods diff --git a/sdk/edgeconnect/appinstance.go b/sdk/edgeconnect/appinstance.go deleted file mode 100644 index 8d568a8..0000000 --- a/sdk/edgeconnect/appinstance.go +++ /dev/null @@ -1,235 +0,0 @@ -// ABOUTME: Application instance lifecycle management APIs for EdgeXR Master Controller -// ABOUTME: Provides typed methods for creating, querying, and deleting application instances - -package edgeconnect - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/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 { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/CreateAppInst" - - resp, err := transport.Call(ctx, "POST", url, input) - if err != nil { - return fmt.Errorf("CreateAppInstance failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - return c.handleErrorResponse(resp, "CreateAppInstance") - } - - c.logf("CreateAppInstance: %s/%s created successfully", - input.AppInst.Key.Organization, input.AppInst.Key.Name) - - return nil -} - -// 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) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" - - filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: appInstKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return AppInstance{}, fmt.Errorf("app instance %s/%s: %w", - appInstKey.Organization, appInstKey.Name, ErrResourceNotFound) - } - - if resp.StatusCode >= 400 { - return AppInstance{}, 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) - } - - if len(appInstances) == 0 { - return AppInstance{}, fmt.Errorf("app instance %s/%s in region %s: %w", - appInstKey.Organization, appInstKey.Name, region, ErrResourceNotFound) - } - - return appInstances[0], 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) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" - - filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: appInstKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("ShowAppInstances failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { - return nil, c.handleErrorResponse(resp, "ShowAppInstances") - } - - var appInstances []AppInstance - if resp.StatusCode == http.StatusNotFound { - return appInstances, nil // Return empty slice for not found - } - - if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { - return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err) - } - - c.logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances)) - return appInstances, 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 { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/UpdateAppInst" - - resp, err := transport.Call(ctx, "POST", url, input) - if err != nil { - return fmt.Errorf("UpdateAppInstance failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - return c.handleErrorResponse(resp, "UpdateAppInstance") - } - - c.logf("UpdateAppInstance: %s/%s updated successfully", - input.AppInst.Key.Organization, input.AppInst.Key.Name) - - return nil -} - -// 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 { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/RefreshAppInst" - - filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: appInstKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return fmt.Errorf("RefreshAppInstance failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - return c.handleErrorResponse(resp, "RefreshAppInstance") - } - - c.logf("RefreshAppInstance: %s/%s refreshed successfully", - appInstKey.Organization, appInstKey.Name) - - return nil -} - -// 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 { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst" - - filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: appInstKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return fmt.Errorf("DeleteAppInstance failed: %w", err) - } - defer resp.Body.Close() - - // 404 is acceptable for delete operations (already deleted) - if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { - return c.handleErrorResponse(resp, "DeleteAppInstance") - } - - c.logf("DeleteAppInstance: %s/%s deleted successfully", - appInstKey.Organization, appInstKey.Name) - - return nil -} - -// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances -func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error { - var responses []Response[AppInstance] - - parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { - var response Response[AppInstance] - if err := json.Unmarshal(line, &response); err != nil { - return err - } - responses = append(responses, response) - return nil - }) - - if parseErr != nil { - return parseErr - } - - // Extract data from responses - var appInstances []AppInstance - var messages []string - - for _, response := range responses { - if response.HasData() { - appInstances = append(appInstances, response.Data) - } - if response.IsMessage() { - messages = append(messages, response.Data.GetMessage()) - } - } - - // If we have error messages, return them - if len(messages) > 0 { - return &APIError{ - StatusCode: resp.StatusCode, - Messages: messages, - } - } - - // Set result based on type - switch v := result.(type) { - case *[]AppInstance: - *v = appInstances - default: - return fmt.Errorf("unsupported result type: %T", result) - } - - return nil -} diff --git a/sdk/edgeconnect/appinstance_test.go b/sdk/edgeconnect/appinstance_test.go deleted file mode 100644 index fc8bfc4..0000000 --- a/sdk/edgeconnect/appinstance_test.go +++ /dev/null @@ -1,469 +0,0 @@ -// ABOUTME: Unit tests for AppInstance management APIs using httptest mock server -// ABOUTME: Tests create, show, list, refresh, and delete operations with error conditions - -package edgeconnect - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCreateAppInstance(t *testing.T) { - tests := []struct { - name string - input *NewAppInstanceInput - mockStatusCode int - mockResponse string - expectError bool - }{ - { - name: "successful creation", - input: &NewAppInstanceInput{ - Region: "us-west", - AppInst: AppInstance{ - Key: AppInstanceKey{ - Organization: "testorg", - Name: "testinst", - CloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - }, - AppKey: AppKey{ - Organization: "testorg", - Name: "testapp", - Version: "1.0.0", - }, - Flavor: Flavor{Name: "m4.small"}, - }, - }, - mockStatusCode: 200, - mockResponse: `{"message": "success"}`, - expectError: false, - }, - { - name: "validation error", - input: &NewAppInstanceInput{ - Region: "us-west", - AppInst: AppInstance{ - Key: AppInstanceKey{ - Organization: "", - Name: "testinst", - }, - }, - }, - mockStatusCode: 400, - mockResponse: `{"message": "organization is required"}`, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/CreateAppInst", r.URL.Path) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - - w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) - })) - defer server.Close() - - // Create client - client := NewClient(server.URL, - WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), - WithAuthProvider(NewStaticTokenProvider("test-token")), - ) - - // Execute test - ctx := context.Background() - err := client.CreateAppInstance(ctx, tt.input) - - // Verify results - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestShowAppInstance(t *testing.T) { - tests := []struct { - name string - appInstKey AppInstanceKey - region string - mockStatusCode int - mockResponse string - expectError bool - expectNotFound bool - }{ - { - name: "successful show", - appInstKey: AppInstanceKey{ - Organization: "testorg", - Name: "testinst", - CloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - }, - region: "us-west", - mockStatusCode: 200, - mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}} -`, - expectError: false, - expectNotFound: false, - }, - { - name: "instance not found", - appInstKey: AppInstanceKey{ - Organization: "testorg", - Name: "nonexistent", - CloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - }, - region: "us-west", - mockStatusCode: 404, - mockResponse: "", - expectError: true, - expectNotFound: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path) - - w.WriteHeader(tt.mockStatusCode) - if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) - } - })) - defer server.Close() - - // Create client - client := NewClient(server.URL, - WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), - ) - - // Execute test - ctx := context.Background() - appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region) - - // Verify results - if tt.expectError { - assert.Error(t, err) - if tt.expectNotFound { - assert.Contains(t, err.Error(), "resource not found") - } - } else { - require.NoError(t, err) - assert.Equal(t, tt.appInstKey.Organization, appInst.Key.Organization) - assert.Equal(t, tt.appInstKey.Name, appInst.Key.Name) - assert.Equal(t, "Ready", appInst.State) - } - }) - } -} - -func TestShowAppInstances(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path) - - // Verify request body - var filter AppInstanceFilter - err := json.NewDecoder(r.Body).Decode(&filter) - require.NoError(t, err) - assert.Equal(t, "testorg", filter.AppInstance.Key.Organization) - assert.Equal(t, "us-west", filter.Region) - - // Return multiple app instances - response := `{"data": {"key": {"organization": "testorg", "name": "inst1"}, "state": "Ready"}} -{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}} -` - w.WriteHeader(200) - w.Write([]byte(response)) - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, "us-west") - - require.NoError(t, err) - assert.Len(t, appInstances, 2) - assert.Equal(t, "inst1", appInstances[0].Key.Name) - assert.Equal(t, "Ready", appInstances[0].State) - assert.Equal(t, "inst2", appInstances[1].Key.Name) - assert.Equal(t, "Creating", appInstances[1].State) -} - -func TestUpdateAppInstance(t *testing.T) { - tests := []struct { - name string - input *UpdateAppInstanceInput - mockStatusCode int - mockResponse string - expectError bool - }{ - { - name: "successful update", - input: &UpdateAppInstanceInput{ - Region: "us-west", - AppInst: AppInstance{ - Key: AppInstanceKey{ - Organization: "testorg", - Name: "testinst", - CloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - }, - AppKey: AppKey{ - Organization: "testorg", - Name: "testapp", - Version: "1.0.0", - }, - Flavor: Flavor{Name: "m4.medium"}, - PowerState: "PowerOn", - }, - }, - mockStatusCode: 200, - mockResponse: `{"message": "success"}`, - expectError: false, - }, - { - name: "validation error", - input: &UpdateAppInstanceInput{ - Region: "us-west", - AppInst: AppInstance{ - Key: AppInstanceKey{ - Organization: "", - Name: "testinst", - CloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - }, - }, - }, - mockStatusCode: 400, - mockResponse: `{"message": "organization is required"}`, - expectError: true, - }, - { - name: "instance not found", - input: &UpdateAppInstanceInput{ - Region: "us-west", - AppInst: AppInstance{ - Key: AppInstanceKey{ - Organization: "testorg", - Name: "nonexistent", - CloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - }, - }, - }, - mockStatusCode: 404, - mockResponse: `{"message": "app instance not found"}`, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/UpdateAppInst", r.URL.Path) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - - // Verify request body - var input UpdateAppInstanceInput - err := json.NewDecoder(r.Body).Decode(&input) - require.NoError(t, err) - assert.Equal(t, tt.input.Region, input.Region) - assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization) - - w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) - })) - defer server.Close() - - // Create client - client := NewClient(server.URL, - WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), - WithAuthProvider(NewStaticTokenProvider("test-token")), - ) - - // Execute test - ctx := context.Background() - err := client.UpdateAppInstance(ctx, tt.input) - - // Verify results - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestRefreshAppInstance(t *testing.T) { - tests := []struct { - name string - appInstKey AppInstanceKey - region string - mockStatusCode int - expectError bool - }{ - { - name: "successful refresh", - appInstKey: AppInstanceKey{ - Organization: "testorg", - Name: "testinst", - CloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - }, - region: "us-west", - mockStatusCode: 200, - expectError: false, - }, - { - name: "server error", - appInstKey: AppInstanceKey{ - Organization: "testorg", - Name: "testinst", - CloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - }, - region: "us-west", - mockStatusCode: 500, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/RefreshAppInst", r.URL.Path) - - w.WriteHeader(tt.mockStatusCode) - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - err := client.RefreshAppInstance(ctx, tt.appInstKey, tt.region) - - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestDeleteAppInstance(t *testing.T) { - tests := []struct { - name string - appInstKey AppInstanceKey - region string - mockStatusCode int - expectError bool - }{ - { - name: "successful deletion", - appInstKey: AppInstanceKey{ - Organization: "testorg", - Name: "testinst", - CloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - }, - region: "us-west", - mockStatusCode: 200, - expectError: false, - }, - { - name: "already deleted (404 ok)", - appInstKey: AppInstanceKey{ - Organization: "testorg", - Name: "testinst", - CloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - }, - region: "us-west", - mockStatusCode: 404, - expectError: false, - }, - { - name: "server error", - appInstKey: AppInstanceKey{ - Organization: "testorg", - Name: "testinst", - CloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - }, - region: "us-west", - mockStatusCode: 500, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/DeleteAppInst", r.URL.Path) - - w.WriteHeader(tt.mockStatusCode) - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - err := client.DeleteAppInstance(ctx, tt.appInstKey, tt.region) - - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/sdk/edgeconnect/apps.go b/sdk/edgeconnect/apps.go deleted file mode 100644 index 70f5dea..0000000 --- a/sdk/edgeconnect/apps.go +++ /dev/null @@ -1,251 +0,0 @@ -// ABOUTME: Application lifecycle management APIs for EdgeXR Master Controller -// ABOUTME: Provides typed methods for creating, querying, and deleting applications - -package edgeconnect - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" -) - -var ( - // ErrResourceNotFound indicates the requested resource was not found - ErrResourceNotFound = fmt.Errorf("resource not found") -) - -// 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 { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/CreateApp" - - resp, err := transport.Call(ctx, "POST", url, input) - if err != nil { - return fmt.Errorf("CreateApp failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - return c.handleErrorResponse(resp, "CreateApp") - } - - c.logf("CreateApp: %s/%s version %s created successfully", - input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version) - - return nil -} - -// 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) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp" - - filter := AppFilter{ - App: App{Key: appKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return App{}, 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", - appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound) - } - - if resp.StatusCode >= 400 { - return App{}, 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) - } - - if len(apps) == 0 { - return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", - appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound) - } - - return apps[0], 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) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp" - - filter := AppFilter{ - App: App{Key: appKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("ShowApps failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { - return nil, c.handleErrorResponse(resp, "ShowApps") - } - - var apps []App - if resp.StatusCode == http.StatusNotFound { - return apps, nil // Return empty slice for not found - } - - if err := c.parseStreamingResponse(resp, &apps); err != nil { - return nil, fmt.Errorf("ShowApps failed to parse response: %w", err) - } - - c.logf("ShowApps: found %d apps matching criteria", len(apps)) - return apps, nil -} - -// UpdateApp updates the definition of an application -// Maps to POST /auth/ctrl/UpdateApp -func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/UpdateApp" - - resp, err := transport.Call(ctx, "POST", url, input) - if err != nil { - return fmt.Errorf("UpdateApp failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - return c.handleErrorResponse(resp, "UpdateApp") - } - - c.logf("UpdateApp: %s/%s version %s updated successfully", - input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version) - - return nil -} - -// 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 { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp" - - filter := AppFilter{ - App: App{Key: appKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return fmt.Errorf("DeleteApp failed: %w", err) - } - defer resp.Body.Close() - - // 404 is acceptable for delete operations (already deleted) - if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { - return c.handleErrorResponse(resp, "DeleteApp") - } - - c.logf("DeleteApp: %s/%s version %s deleted successfully", - appKey.Organization, appKey.Name, appKey.Version) - - return nil -} - -// parseStreamingResponse parses the EdgeXR streaming JSON response format -func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error { - var responses []Response[App] - - parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { - var response Response[App] - if err := json.Unmarshal(line, &response); err != nil { - return err - } - responses = append(responses, response) - return nil - }) - - if parseErr != nil { - return parseErr - } - - // Extract data from responses - var apps []App - var messages []string - - for _, response := range responses { - if response.HasData() { - apps = append(apps, response.Data) - } - if response.IsMessage() { - messages = append(messages, response.Data.GetMessage()) - } - } - - // If we have error messages, return them - if len(messages) > 0 { - return &APIError{ - StatusCode: resp.StatusCode, - Messages: messages, - } - } - - // Set result based on type - switch v := result.(type) { - case *[]App: - *v = apps - default: - return fmt.Errorf("unsupported result type: %T", result) - } - - return nil -} - -// getTransport creates an HTTP transport with current client settings -func (c *Client) getTransport() *sdkhttp.Transport { - return sdkhttp.NewTransport( - sdkhttp.RetryOptions{ - MaxRetries: c.RetryOpts.MaxRetries, - InitialDelay: c.RetryOpts.InitialDelay, - MaxDelay: c.RetryOpts.MaxDelay, - Multiplier: c.RetryOpts.Multiplier, - RetryableHTTPStatusCodes: c.RetryOpts.RetryableHTTPStatusCodes, - }, - c.AuthProvider, - c.Logger, - ) -} - -// handleErrorResponse creates an appropriate error from HTTP error response -func (c *Client) handleErrorResponse(resp *http.Response, operation string) error { - - messages := []string{ - fmt.Sprintf("%s failed with status %d", operation, resp.StatusCode), - } - - bodyBytes := []byte{} - - if resp.Body != nil { - defer resp.Body.Close() - bodyBytes, _ = io.ReadAll(resp.Body) - messages = append(messages, string(bodyBytes)) - } - - return &APIError{ - StatusCode: resp.StatusCode, - Messages: messages, - Body: bodyBytes, - } -} diff --git a/sdk/edgeconnect/apps_test.go b/sdk/edgeconnect/apps_test.go deleted file mode 100644 index 30531f6..0000000 --- a/sdk/edgeconnect/apps_test.go +++ /dev/null @@ -1,419 +0,0 @@ -// ABOUTME: Unit tests for App management APIs using httptest mock server -// ABOUTME: Tests create, show, list, and delete operations with error conditions - -package edgeconnect - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCreateApp(t *testing.T) { - tests := []struct { - name string - input *NewAppInput - mockStatusCode int - mockResponse string - expectError bool - }{ - { - name: "successful creation", - input: &NewAppInput{ - Region: "us-west", - App: App{ - Key: AppKey{ - Organization: "testorg", - Name: "testapp", - Version: "1.0.0", - }, - Deployment: "kubernetes", - }, - }, - mockStatusCode: 200, - mockResponse: `{"message": "success"}`, - expectError: false, - }, - { - name: "validation error", - input: &NewAppInput{ - Region: "us-west", - App: App{ - Key: AppKey{ - Organization: "", - Name: "testapp", - Version: "1.0.0", - }, - }, - }, - mockStatusCode: 400, - mockResponse: `{"message": "organization is required"}`, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/CreateApp", r.URL.Path) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - - w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) - })) - defer server.Close() - - // Create client - client := NewClient(server.URL, - WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), - WithAuthProvider(NewStaticTokenProvider("test-token")), - ) - - // Execute test - ctx := context.Background() - err := client.CreateApp(ctx, tt.input) - - // Verify results - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestShowApp(t *testing.T) { - tests := []struct { - name string - appKey AppKey - region string - mockStatusCode int - mockResponse string - expectError bool - expectNotFound bool - }{ - { - name: "successful show", - appKey: AppKey{ - Organization: "testorg", - Name: "testapp", - Version: "1.0.0", - }, - region: "us-west", - mockStatusCode: 200, - mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testapp", "version": "1.0.0"}, "deployment": "kubernetes"}} -`, - expectError: false, - expectNotFound: false, - }, - { - name: "app not found", - appKey: AppKey{ - Organization: "testorg", - Name: "nonexistent", - Version: "1.0.0", - }, - region: "us-west", - mockStatusCode: 404, - mockResponse: "", - expectError: true, - expectNotFound: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path) - - w.WriteHeader(tt.mockStatusCode) - if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) - } - })) - defer server.Close() - - // Create client - client := NewClient(server.URL, - WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), - ) - - // Execute test - ctx := context.Background() - app, err := client.ShowApp(ctx, tt.appKey, tt.region) - - // Verify results - if tt.expectError { - assert.Error(t, err) - if tt.expectNotFound { - assert.Contains(t, err.Error(), "resource not found") - } - } else { - require.NoError(t, err) - assert.Equal(t, tt.appKey.Organization, app.Key.Organization) - assert.Equal(t, tt.appKey.Name, app.Key.Name) - assert.Equal(t, tt.appKey.Version, app.Key.Version) - } - }) - } -} - -func TestShowApps(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path) - - // Verify request body - var filter AppFilter - err := json.NewDecoder(r.Body).Decode(&filter) - require.NoError(t, err) - assert.Equal(t, "testorg", filter.App.Key.Organization) - assert.Equal(t, "us-west", filter.Region) - - // Return multiple apps - response := `{"data": {"key": {"organization": "testorg", "name": "app1", "version": "1.0.0"}, "deployment": "kubernetes"}} -{"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}} -` - w.WriteHeader(200) - w.Write([]byte(response)) - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - apps, err := client.ShowApps(ctx, AppKey{Organization: "testorg"}, "us-west") - - require.NoError(t, err) - assert.Len(t, apps, 2) - assert.Equal(t, "app1", apps[0].Key.Name) - assert.Equal(t, "app2", apps[1].Key.Name) -} - -func TestUpdateApp(t *testing.T) { - tests := []struct { - name string - input *UpdateAppInput - mockStatusCode int - mockResponse string - expectError bool - }{ - { - name: "successful update", - input: &UpdateAppInput{ - Region: "us-west", - App: App{ - Key: AppKey{ - Organization: "testorg", - Name: "testapp", - Version: "1.0.0", - }, - Deployment: "kubernetes", - ImagePath: "nginx:latest", - }, - }, - mockStatusCode: 200, - mockResponse: `{"message": "success"}`, - expectError: false, - }, - { - name: "validation error", - input: &UpdateAppInput{ - Region: "us-west", - App: App{ - Key: AppKey{ - Organization: "", - Name: "testapp", - Version: "1.0.0", - }, - }, - }, - mockStatusCode: 400, - mockResponse: `{"message": "organization is required"}`, - expectError: true, - }, - { - name: "app not found", - input: &UpdateAppInput{ - Region: "us-west", - App: App{ - Key: AppKey{ - Organization: "testorg", - Name: "nonexistent", - Version: "1.0.0", - }, - }, - }, - mockStatusCode: 404, - mockResponse: `{"message": "app not found"}`, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/UpdateApp", r.URL.Path) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - - // Verify request body - var input UpdateAppInput - err := json.NewDecoder(r.Body).Decode(&input) - require.NoError(t, err) - assert.Equal(t, tt.input.Region, input.Region) - assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization) - - w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) - })) - defer server.Close() - - // Create client - client := NewClient(server.URL, - WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), - WithAuthProvider(NewStaticTokenProvider("test-token")), - ) - - // Execute test - ctx := context.Background() - err := client.UpdateApp(ctx, tt.input) - - // Verify results - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestDeleteApp(t *testing.T) { - tests := []struct { - name string - appKey AppKey - region string - mockStatusCode int - expectError bool - }{ - { - name: "successful deletion", - appKey: AppKey{ - Organization: "testorg", - Name: "testapp", - Version: "1.0.0", - }, - region: "us-west", - mockStatusCode: 200, - expectError: false, - }, - { - name: "already deleted (404 ok)", - appKey: AppKey{ - Organization: "testorg", - Name: "testapp", - Version: "1.0.0", - }, - region: "us-west", - mockStatusCode: 404, - expectError: false, - }, - { - name: "server error", - appKey: AppKey{ - Organization: "testorg", - Name: "testapp", - Version: "1.0.0", - }, - region: "us-west", - mockStatusCode: 500, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/DeleteApp", r.URL.Path) - - w.WriteHeader(tt.mockStatusCode) - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - err := client.DeleteApp(ctx, tt.appKey, tt.region) - - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestClientOptions(t *testing.T) { - t.Run("with auth provider", func(t *testing.T) { - authProvider := NewStaticTokenProvider("test-token") - client := NewClient("https://example.com", - WithAuthProvider(authProvider), - ) - - assert.Equal(t, authProvider, client.AuthProvider) - }) - - t.Run("with custom HTTP client", func(t *testing.T) { - httpClient := &http.Client{Timeout: 10 * time.Second} - client := NewClient("https://example.com", - WithHTTPClient(httpClient), - ) - - assert.Equal(t, httpClient, client.HTTPClient) - }) - - t.Run("with retry options", func(t *testing.T) { - retryOpts := RetryOptions{MaxRetries: 5} - client := NewClient("https://example.com", - WithRetryOptions(retryOpts), - ) - - assert.Equal(t, 5, client.RetryOpts.MaxRetries) - }) -} - -func TestAPIError(t *testing.T) { - err := &APIError{ - StatusCode: 400, - Messages: []string{"validation failed", "name is required"}, - } - - assert.Contains(t, err.Error(), "validation failed") - assert.Equal(t, 400, err.StatusCode) - assert.Len(t, err.Messages, 2) -} - -// Helper function to create a test server that handles streaming JSON responses -func createStreamingJSONServer(responses []string, statusCode int) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(statusCode) - for _, response := range responses { - w.Write([]byte(response + "\n")) - } - })) -} diff --git a/sdk/edgeconnect/auth_test.go b/sdk/edgeconnect/auth_test.go deleted file mode 100644 index 8ea3176..0000000 --- a/sdk/edgeconnect/auth_test.go +++ /dev/null @@ -1,226 +0,0 @@ -// ABOUTME: Unit tests for authentication providers including username/password token flow -// ABOUTME: Tests token caching, login flow, and error conditions with mock servers - -package edgeconnect - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestStaticTokenProvider(t *testing.T) { - provider := NewStaticTokenProvider("test-token-123") - - req, _ := http.NewRequest("GET", "https://example.com", nil) - ctx := context.Background() - - err := provider.Attach(ctx, req) - - require.NoError(t, err) - assert.Equal(t, "Bearer test-token-123", req.Header.Get("Authorization")) -} - -func TestStaticTokenProvider_EmptyToken(t *testing.T) { - provider := NewStaticTokenProvider("") - - req, _ := http.NewRequest("GET", "https://example.com", nil) - ctx := context.Background() - - err := provider.Attach(ctx, req) - - require.NoError(t, err) - assert.Empty(t, req.Header.Get("Authorization")) -} - -func TestUsernamePasswordProvider_Success(t *testing.T) { - // Mock login server - loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/login", r.URL.Path) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - - // Verify request body - var creds map[string]string - err := json.NewDecoder(r.Body).Decode(&creds) - require.NoError(t, err) - assert.Equal(t, "testuser", creds["username"]) - assert.Equal(t, "testpass", creds["password"]) - - // Return token - response := map[string]string{"token": "dynamic-token-456"} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - })) - defer loginServer.Close() - - provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil) - - req, _ := http.NewRequest("GET", "https://api.example.com", nil) - ctx := context.Background() - - err := provider.Attach(ctx, req) - - require.NoError(t, err) - assert.Equal(t, "Bearer dynamic-token-456", req.Header.Get("Authorization")) -} - -func TestUsernamePasswordProvider_LoginFailure(t *testing.T) { - // Mock login server that returns error - loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("Invalid credentials")) - })) - defer loginServer.Close() - - provider := NewUsernamePasswordProvider(loginServer.URL, "baduser", "badpass", nil) - - req, _ := http.NewRequest("GET", "https://api.example.com", nil) - ctx := context.Background() - - err := provider.Attach(ctx, req) - - require.Error(t, err) - assert.Contains(t, err.Error(), "login failed with status 401") - assert.Contains(t, err.Error(), "Invalid credentials") -} - -func TestUsernamePasswordProvider_TokenCaching(t *testing.T) { - callCount := 0 - - // Mock login server that tracks calls - loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - callCount++ - response := map[string]string{"token": "cached-token-789"} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - })) - defer loginServer.Close() - - provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil) - ctx := context.Background() - - // First request should call login - req1, _ := http.NewRequest("GET", "https://api.example.com", nil) - err1 := provider.Attach(ctx, req1) - require.NoError(t, err1) - assert.Equal(t, "Bearer cached-token-789", req1.Header.Get("Authorization")) - assert.Equal(t, 1, callCount) - - // Second request should use cached token (no additional login call) - req2, _ := http.NewRequest("GET", "https://api.example.com", nil) - err2 := provider.Attach(ctx, req2) - require.NoError(t, err2) - assert.Equal(t, "Bearer cached-token-789", req2.Header.Get("Authorization")) - assert.Equal(t, 1, callCount) // Still only 1 call -} - -func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) { - callCount := 0 - - loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - callCount++ - response := map[string]string{"token": "refreshed-token-999"} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - })) - defer loginServer.Close() - - provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil) - - // Manually set expired token - provider.mu.Lock() - provider.cachedToken = "expired-token" - provider.tokenExpiry = time.Now().Add(-1 * time.Hour) // Already expired - provider.mu.Unlock() - - ctx := context.Background() - req, _ := http.NewRequest("GET", "https://api.example.com", nil) - - err := provider.Attach(ctx, req) - - require.NoError(t, err) - assert.Equal(t, "Bearer refreshed-token-999", req.Header.Get("Authorization")) - assert.Equal(t, 1, callCount) // New token retrieved -} - -func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) { - callCount := 0 - - loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - callCount++ - response := map[string]string{"token": "new-token-after-invalidation"} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - })) - defer loginServer.Close() - - provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil) - ctx := context.Background() - - // First request to get token - req1, _ := http.NewRequest("GET", "https://api.example.com", nil) - err1 := provider.Attach(ctx, req1) - require.NoError(t, err1) - assert.Equal(t, 1, callCount) - - // Invalidate token - provider.InvalidateToken() - - // Next request should get new token - req2, _ := http.NewRequest("GET", "https://api.example.com", nil) - err2 := provider.Attach(ctx, req2) - require.NoError(t, err2) - assert.Equal(t, "Bearer new-token-after-invalidation", req2.Header.Get("Authorization")) - assert.Equal(t, 2, callCount) // New login call made -} - -func TestUsernamePasswordProvider_BadJSONResponse(t *testing.T) { - // Mock server returning invalid JSON - loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte("invalid json response")) - })) - defer loginServer.Close() - - provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil) - - req, _ := http.NewRequest("GET", "https://api.example.com", nil) - ctx := context.Background() - - err := provider.Attach(ctx, req) - - require.Error(t, err) - assert.Contains(t, err.Error(), "error parsing JSON") -} - -func TestNoAuthProvider(t *testing.T) { - provider := NewNoAuthProvider() - - req, _ := http.NewRequest("GET", "https://example.com", nil) - ctx := context.Background() - - err := provider.Attach(ctx, req) - - require.NoError(t, err) - assert.Empty(t, req.Header.Get("Authorization")) -} - -func TestNewClientWithCredentials(t *testing.T) { - client := NewClientWithCredentials("https://example.com", "testuser", "testpass") - - assert.Equal(t, "https://example.com", client.BaseURL) - - // Check that auth provider is UsernamePasswordProvider - authProvider, ok := client.AuthProvider.(*UsernamePasswordProvider) - require.True(t, ok, "AuthProvider should be UsernamePasswordProvider") - assert.Equal(t, "testuser", authProvider.Username) - assert.Equal(t, "testpass", authProvider.Password) - assert.Equal(t, "https://example.com", authProvider.BaseURL) -} diff --git a/sdk/edgeconnect/client.go b/sdk/edgeconnect/client.go deleted file mode 100644 index 2a79cff..0000000 --- a/sdk/edgeconnect/client.go +++ /dev/null @@ -1,122 +0,0 @@ -// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth -// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations - -package edgeconnect - -import ( - "net/http" - "strings" - "time" -) - -// Client represents the EdgeXR Master Controller SDK client -type Client struct { - BaseURL string - HTTPClient *http.Client - AuthProvider AuthProvider - RetryOpts RetryOptions - Logger Logger -} - -// RetryOptions configures retry behavior for API calls -type RetryOptions struct { - MaxRetries int - InitialDelay time.Duration - MaxDelay time.Duration - Multiplier float64 - RetryableHTTPStatusCodes []int -} - -// Logger interface for optional logging -type Logger interface { - Printf(format string, v ...interface{}) -} - -// DefaultRetryOptions returns sensible default retry configuration -func DefaultRetryOptions() RetryOptions { - return RetryOptions{ - MaxRetries: 3, - InitialDelay: 1 * time.Second, - MaxDelay: 30 * time.Second, - Multiplier: 2.0, - RetryableHTTPStatusCodes: []int{ - http.StatusRequestTimeout, - http.StatusTooManyRequests, - http.StatusInternalServerError, - http.StatusBadGateway, - http.StatusServiceUnavailable, - http.StatusGatewayTimeout, - }, - } -} - -// Option represents a configuration option for the client -type Option func(*Client) - -// WithHTTPClient sets a custom HTTP client -func WithHTTPClient(client *http.Client) Option { - return func(c *Client) { - c.HTTPClient = client - } -} - -// WithAuthProvider sets the authentication provider -func WithAuthProvider(auth AuthProvider) Option { - return func(c *Client) { - c.AuthProvider = auth - } -} - -// WithRetryOptions sets retry configuration -func WithRetryOptions(opts RetryOptions) Option { - return func(c *Client) { - c.RetryOpts = opts - } -} - -// WithLogger sets a logger for debugging -func WithLogger(logger Logger) Option { - return func(c *Client) { - c.Logger = logger - } -} - -// NewClient creates a new EdgeXR SDK client -func NewClient(baseURL string, options ...Option) *Client { - client := &Client{ - BaseURL: strings.TrimRight(baseURL, "/"), - HTTPClient: &http.Client{Timeout: 30 * time.Second}, - AuthProvider: NewNoAuthProvider(), - RetryOpts: DefaultRetryOptions(), - } - - for _, opt := range options { - opt(client) - } - - return client -} - -// NewClientWithCredentials creates a new EdgeXR SDK client with username/password authentication -// This matches the existing client pattern from client/client.go -func NewClientWithCredentials(baseURL, username, password string, options ...Option) *Client { - client := &Client{ - BaseURL: strings.TrimRight(baseURL, "/"), - HTTPClient: &http.Client{Timeout: 30 * time.Second}, - AuthProvider: NewUsernamePasswordProvider(baseURL, username, password, nil), - RetryOpts: DefaultRetryOptions(), - } - - for _, opt := range options { - opt(client) - } - - return client -} - -// logf logs a message if a logger is configured -func (c *Client) logf(format string, v ...interface{}) { - if c.Logger != nil { - c.Logger.Printf(format, v...) - } -} diff --git a/sdk/edgeconnect/cloudlet.go b/sdk/edgeconnect/cloudlet.go deleted file mode 100644 index e3f4b7d..0000000 --- a/sdk/edgeconnect/cloudlet.go +++ /dev/null @@ -1,271 +0,0 @@ -// ABOUTME: Cloudlet management APIs for EdgeXR Master Controller -// ABOUTME: Provides typed methods for creating, querying, and managing edge cloudlets - -package edgeconnect - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/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 { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/CreateCloudlet" - - resp, err := transport.Call(ctx, "POST", url, input) - if err != nil { - return fmt.Errorf("CreateCloudlet failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - return c.handleErrorResponse(resp, "CreateCloudlet") - } - - c.logf("CreateCloudlet: %s/%s created successfully", - input.Cloudlet.Key.Organization, input.Cloudlet.Key.Name) - - return nil -} - -// 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) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet" - - filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: cloudletKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return Cloudlet{}, 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", - cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) - } - - if resp.StatusCode >= 400 { - return Cloudlet{}, 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) - } - - if len(cloudlets) == 0 { - return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", - cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) - } - - return cloudlets[0], 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) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet" - - filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: cloudletKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("ShowCloudlets failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { - return nil, c.handleErrorResponse(resp, "ShowCloudlets") - } - - var cloudlets []Cloudlet - if resp.StatusCode == http.StatusNotFound { - return cloudlets, nil // Return empty slice for not found - } - - if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil { - return nil, fmt.Errorf("ShowCloudlets failed to parse response: %w", err) - } - - c.logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets)) - return cloudlets, 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 { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/DeleteCloudlet" - - filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: cloudletKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return fmt.Errorf("DeleteCloudlet failed: %w", err) - } - defer resp.Body.Close() - - // 404 is acceptable for delete operations (already deleted) - if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { - return c.handleErrorResponse(resp, "DeleteCloudlet") - } - - c.logf("DeleteCloudlet: %s/%s deleted successfully", - cloudletKey.Organization, cloudletKey.Name) - - return nil -} - -// 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) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletManifest" - - filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: cloudletKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("GetCloudletManifest failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w", - cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) - } - - if resp.StatusCode >= 400 { - return nil, c.handleErrorResponse(resp, "GetCloudletManifest") - } - - // Parse the response as CloudletManifest - var manifest CloudletManifest - if err := c.parseDirectJSONResponse(resp, &manifest); err != nil { - return nil, fmt.Errorf("GetCloudletManifest failed to parse response: %w", err) - } - - c.logf("GetCloudletManifest: retrieved manifest for %s/%s", - cloudletKey.Organization, cloudletKey.Name) - - return &manifest, nil -} - -// 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) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletResourceUsage" - - filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: cloudletKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w", - cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) - } - - if resp.StatusCode >= 400 { - return nil, c.handleErrorResponse(resp, "GetCloudletResourceUsage") - } - - // Parse the response as CloudletResourceUsage - var usage CloudletResourceUsage - if err := c.parseDirectJSONResponse(resp, &usage); err != nil { - return nil, fmt.Errorf("GetCloudletResourceUsage failed to parse response: %w", err) - } - - c.logf("GetCloudletResourceUsage: retrieved usage for %s/%s", - cloudletKey.Organization, cloudletKey.Name) - - return &usage, nil -} - -// parseStreamingCloudletResponse parses the EdgeXR streaming JSON response format for cloudlets -func (c *Client) parseStreamingCloudletResponse(resp *http.Response, result interface{}) error { - var responses []Response[Cloudlet] - - parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { - var response Response[Cloudlet] - if err := json.Unmarshal(line, &response); err != nil { - return err - } - responses = append(responses, response) - return nil - }) - - if parseErr != nil { - return parseErr - } - - // Extract data from responses - var cloudlets []Cloudlet - var messages []string - - for _, response := range responses { - if response.HasData() { - cloudlets = append(cloudlets, response.Data) - } - if response.IsMessage() { - messages = append(messages, response.Data.GetMessage()) - } - } - - // If we have error messages, return them - if len(messages) > 0 { - return &APIError{ - StatusCode: resp.StatusCode, - Messages: messages, - } - } - - // Set result based on type - switch v := result.(type) { - case *[]Cloudlet: - *v = cloudlets - default: - return fmt.Errorf("unsupported result type: %T", result) - } - - return nil -} - -// parseDirectJSONResponse parses a direct JSON response (not streaming) -func (c *Client) parseDirectJSONResponse(resp *http.Response, result interface{}) error { - decoder := json.NewDecoder(resp.Body) - if err := decoder.Decode(result); err != nil { - return fmt.Errorf("failed to decode JSON response: %w", err) - } - return nil -} diff --git a/sdk/edgeconnect/cloudlet_test.go b/sdk/edgeconnect/cloudlet_test.go deleted file mode 100644 index 7d129bb..0000000 --- a/sdk/edgeconnect/cloudlet_test.go +++ /dev/null @@ -1,408 +0,0 @@ -// ABOUTME: Unit tests for Cloudlet management APIs using httptest mock server -// ABOUTME: Tests create, show, list, delete, manifest, and resource usage operations - -package edgeconnect - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCreateCloudlet(t *testing.T) { - tests := []struct { - name string - input *NewCloudletInput - mockStatusCode int - mockResponse string - expectError bool - }{ - { - name: "successful creation", - input: &NewCloudletInput{ - Region: "us-west", - Cloudlet: Cloudlet{ - Key: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - Location: Location{ - Latitude: 37.7749, - Longitude: -122.4194, - }, - IpSupport: "IpSupportDynamic", - NumDynamicIps: 10, - }, - }, - mockStatusCode: 200, - mockResponse: `{"message": "success"}`, - expectError: false, - }, - { - name: "validation error", - input: &NewCloudletInput{ - Region: "us-west", - Cloudlet: Cloudlet{ - Key: CloudletKey{ - Organization: "", - Name: "testcloudlet", - }, - }, - }, - mockStatusCode: 400, - mockResponse: `{"message": "organization is required"}`, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/CreateCloudlet", r.URL.Path) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - - w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) - })) - defer server.Close() - - // Create client - client := NewClient(server.URL, - WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), - WithAuthProvider(NewStaticTokenProvider("test-token")), - ) - - // Execute test - ctx := context.Background() - err := client.CreateCloudlet(ctx, tt.input) - - // Verify results - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestShowCloudlet(t *testing.T) { - tests := []struct { - name string - cloudletKey CloudletKey - region string - mockStatusCode int - mockResponse string - expectError bool - expectNotFound bool - }{ - { - name: "successful show", - cloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - region: "us-west", - mockStatusCode: 200, - mockResponse: `{"data": {"key": {"organization": "cloudletorg", "name": "testcloudlet"}, "state": "Ready", "location": {"latitude": 37.7749, "longitude": -122.4194}}} -`, - expectError: false, - expectNotFound: false, - }, - { - name: "cloudlet not found", - cloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "nonexistent", - }, - region: "us-west", - mockStatusCode: 404, - mockResponse: "", - expectError: true, - expectNotFound: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path) - - w.WriteHeader(tt.mockStatusCode) - if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) - } - })) - defer server.Close() - - // Create client - client := NewClient(server.URL, - WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), - ) - - // Execute test - ctx := context.Background() - cloudlet, err := client.ShowCloudlet(ctx, tt.cloudletKey, tt.region) - - // Verify results - if tt.expectError { - assert.Error(t, err) - if tt.expectNotFound { - assert.Contains(t, err.Error(), "resource not found") - } - } else { - require.NoError(t, err) - assert.Equal(t, tt.cloudletKey.Organization, cloudlet.Key.Organization) - assert.Equal(t, tt.cloudletKey.Name, cloudlet.Key.Name) - assert.Equal(t, "Ready", cloudlet.State) - } - }) - } -} - -func TestShowCloudlets(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path) - - // Verify request body - var filter CloudletFilter - err := json.NewDecoder(r.Body).Decode(&filter) - require.NoError(t, err) - assert.Equal(t, "cloudletorg", filter.Cloudlet.Key.Organization) - assert.Equal(t, "us-west", filter.Region) - - // Return multiple cloudlets - response := `{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet1"}, "state": "Ready"}} -{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}} -` - w.WriteHeader(200) - w.Write([]byte(response)) - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - cloudlets, err := client.ShowCloudlets(ctx, CloudletKey{Organization: "cloudletorg"}, "us-west") - - require.NoError(t, err) - assert.Len(t, cloudlets, 2) - assert.Equal(t, "cloudlet1", cloudlets[0].Key.Name) - assert.Equal(t, "Ready", cloudlets[0].State) - assert.Equal(t, "cloudlet2", cloudlets[1].Key.Name) - assert.Equal(t, "Creating", cloudlets[1].State) -} - -func TestDeleteCloudlet(t *testing.T) { - tests := []struct { - name string - cloudletKey CloudletKey - region string - mockStatusCode int - expectError bool - }{ - { - name: "successful deletion", - cloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - region: "us-west", - mockStatusCode: 200, - expectError: false, - }, - { - name: "already deleted (404 ok)", - cloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - region: "us-west", - mockStatusCode: 404, - expectError: false, - }, - { - name: "server error", - cloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - region: "us-west", - mockStatusCode: 500, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/DeleteCloudlet", r.URL.Path) - - w.WriteHeader(tt.mockStatusCode) - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - err := client.DeleteCloudlet(ctx, tt.cloudletKey, tt.region) - - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestGetCloudletManifest(t *testing.T) { - tests := []struct { - name string - cloudletKey CloudletKey - region string - mockStatusCode int - mockResponse string - expectError bool - expectNotFound bool - }{ - { - name: "successful manifest retrieval", - cloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - region: "us-west", - mockStatusCode: 200, - mockResponse: `{"manifest": "apiVersion: v1\nkind: Deployment\nmetadata:\n name: test", "last_modified": "2024-01-01T00:00:00Z"}`, - expectError: false, - expectNotFound: false, - }, - { - name: "manifest not found", - cloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "nonexistent", - }, - region: "us-west", - mockStatusCode: 404, - mockResponse: "", - expectError: true, - expectNotFound: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletManifest", r.URL.Path) - - w.WriteHeader(tt.mockStatusCode) - if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) - } - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - manifest, err := client.GetCloudletManifest(ctx, tt.cloudletKey, tt.region) - - if tt.expectError { - assert.Error(t, err) - if tt.expectNotFound { - assert.Contains(t, err.Error(), "resource not found") - } - } else { - require.NoError(t, err) - assert.NotNil(t, manifest) - assert.Contains(t, manifest.Manifest, "apiVersion: v1") - } - }) - } -} - -func TestGetCloudletResourceUsage(t *testing.T) { - tests := []struct { - name string - cloudletKey CloudletKey - region string - mockStatusCode int - mockResponse string - expectError bool - expectNotFound bool - }{ - { - name: "successful usage retrieval", - cloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "testcloudlet", - }, - region: "us-west", - mockStatusCode: 200, - mockResponse: `{"cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}, "region": "us-west", "usage": {"cpu": "50%", "memory": "30%", "disk": "20%"}}`, - expectError: false, - expectNotFound: false, - }, - { - name: "usage not found", - cloudletKey: CloudletKey{ - Organization: "cloudletorg", - Name: "nonexistent", - }, - region: "us-west", - mockStatusCode: 404, - mockResponse: "", - expectError: true, - expectNotFound: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletResourceUsage", r.URL.Path) - - w.WriteHeader(tt.mockStatusCode) - if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) - } - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - usage, err := client.GetCloudletResourceUsage(ctx, tt.cloudletKey, tt.region) - - if tt.expectError { - assert.Error(t, err) - if tt.expectNotFound { - assert.Contains(t, err.Error(), "resource not found") - } - } else { - require.NoError(t, err) - assert.NotNil(t, usage) - assert.Equal(t, "cloudletorg", usage.CloudletKey.Organization) - assert.Equal(t, "testcloudlet", usage.CloudletKey.Name) - assert.Equal(t, "us-west", usage.Region) - assert.Contains(t, usage.Usage, "cpu") - } - }) - } -} diff --git a/sdk/examples/comprehensive/main.go b/sdk/examples/comprehensive/main.go index 616279f..c65a158 100644 --- a/sdk/examples/comprehensive/main.go +++ b/sdk/examples/comprehensive/main.go @@ -9,40 +9,43 @@ import ( "log" "net/http" "os" - "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client" ) func main() { // Configure SDK client - baseURL := getEnvOrDefault("EDGEXR_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live") + baseURL := getEnvOrDefault("EDGE_CONNECT_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live") // Support both authentication methods - token := getEnvOrDefault("EDGEXR_TOKEN", "") - username := getEnvOrDefault("EDGEXR_USERNAME", "") - password := getEnvOrDefault("EDGEXR_PASSWORD", "") + token := getEnvOrDefault("EDGE_CONNECT_TOKEN", "") + username := getEnvOrDefault("EDGE_CONNECT_USERNAME", "") + password := getEnvOrDefault("EDGE_CONNECT_PASSWORD", "") - var client *edgeconnect.Client + var client *edgeconnect_client.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()), + client = edgeconnect_client.NewClient(baseURL, + edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)), + edgeconnect_client.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()), + client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password, + edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect_client.WithLogger(log.Default()), ) } else { - log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD") + log.Fatal("Authentication required: Set either EDGE_CONNECT_TOKEN or both EDGE_CONNECT_USERNAME and EDGE_CONNECT_PASSWORD") } + adapter := edgeconnect.NewAdapter(client) + ctx := context.Background() // Configuration for the workflow @@ -61,7 +64,7 @@ func main() { fmt.Printf("Organization: %s, Region: %s\n\n", config.Organization, config.Region) // Run the complete workflow - if err := runComprehensiveWorkflow(ctx, client, config); err != nil { + if err := runComprehensiveWorkflow(ctx, adapter, config); err != nil { log.Fatalf("Workflow failed: %v", err) } @@ -85,15 +88,19 @@ type WorkflowConfig struct { FlavorName string } -func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config WorkflowConfig) error { +func runComprehensiveWorkflow(ctx context.Context, adapter *edgeconnect.Adapter, 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 fmt.Println("\n1️⃣ Creating application...") - app := &edgeconnect.NewAppInput{ + app := &NewAppInput{ Region: config.Region, - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ + App: App{ + Key: AppKey{ Organization: config.Organization, Name: config.AppName, Version: config.AppVersion, @@ -101,10 +108,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Deployment: "kubernetes", ImageType: "ImageTypeDocker", // field is ignored ImagePath: "https://registry-1.docker.io/library/nginx:latest", // must be set. Even for kubernetes - DefaultFlavor: edgeconnect.Flavor{Name: config.FlavorName}, + DefaultFlavor: Flavor{Name: config.FlavorName}, ServerlessConfig: struct{}{}, // must be set AllowServerless: true, // must be set to true for kubernetes - RequiredOutboundConnections: []edgeconnect.SecurityRule{ + RequiredOutboundConnections: []SecurityRule{ { Protocol: "tcp", PortRangeMin: 80, @@ -121,20 +128,40 @@ 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: toDomainSecurityRules(app.App.RequiredOutboundConnections), + } + + if err := adapter.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) // 2. Show Application Details fmt.Println("\n2️⃣ Querying application details...") - appKey := edgeconnect.AppKey{ + appKey := AppKey{ Organization: config.Organization, Name: config.AppName, 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 := adapter.ShowApp(ctx, config.Region, domainAppKey) if err != nil { return fmt.Errorf("failed to show app: %w", err) } @@ -146,8 +173,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 := adapter.ShowApps(ctx, config.Region, filter) if err != nil { return fmt.Errorf("failed to list apps: %w", err) } @@ -160,23 +187,39 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 4. Create Application Instance fmt.Println("\n4️⃣ Creating application instance...") - instance := &edgeconnect.NewAppInstanceInput{ + instance := &NewAppInstanceInput{ Region: config.Region, - AppInst: edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ + AppInst: AppInstance{ + Key: AppInstanceKey{ Organization: config.Organization, Name: config.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: CloudletKey{ Organization: config.CloudletOrg, Name: config.CloudletName, }, }, AppKey: appKey, - Flavor: edgeconnect.Flavor{Name: config.FlavorName}, + Flavor: Flavor{Name: config.FlavorName}, }, } - 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 := adapter.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", @@ -184,16 +227,16 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 5. Wait for Application Instance to be Ready fmt.Println("\n5️⃣ Waiting for application instance to be ready...") - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := AppInstanceKey{ Organization: config.Organization, Name: config.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: CloudletKey{ Organization: config.CloudletOrg, Name: config.CloudletName, }, } - instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, config.Region, 5*time.Minute) + instanceDetails, err := waitForInstanceReady(ctx, adapter, instanceKey, config.Region, 5*time.Minute) if err != nil { return fmt.Errorf("failed to wait for instance ready: %w", err) } @@ -207,7 +250,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 := adapter.ShowAppInstances(ctx, config.Region, domainAppInstKey) if err != nil { return fmt.Errorf("failed to list app instances: %w", err) } @@ -219,7 +263,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 := adapter.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) @@ -228,12 +280,16 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 8. Show Cloudlet Details fmt.Println("\n8️⃣ Querying cloudlet information...") - cloudletKey := edgeconnect.CloudletKey{ + cloudletKey := CloudletKey{ Organization: config.CloudletOrg, Name: config.CloudletName, } - cloudlets, err := c.ShowCloudlets(ctx, cloudletKey, config.Region) + domainCloudletKey = domain.CloudletKey{ + Organization: cloudletKey.Organization, + Name: cloudletKey.Name, + } + cloudlets, err := adapter.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 +305,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 := adapter.GetCloudletManifest(ctx, domainCloudletKey, config.Region) if err != nil { fmt.Printf("⚠️ Could not retrieve cloudlet manifest: %v\n", err) } else { @@ -258,7 +318,11 @@ 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) + domainCloudletKey = domain.CloudletKey{ + Organization: cloudletKey.Organization, + Name: cloudletKey.Name, + } + usage, err := adapter.GetCloudletResourceUsage(ctx, domainCloudletKey, config.Region) if err != nil { fmt.Printf("⚠️ Could not retrieve cloudlet usage: %v\n", err) } else { @@ -272,22 +336,40 @@ 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 := adapter.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 := adapter.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) - if err != nil && fmt.Sprintf("%v", err) == edgeconnect.ErrResourceNotFound.Error() { + domainAppKey = domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } + _, err = adapter.ShowApp(ctx, config.Region, domainAppKey) + if err != nil && domain.IsNotFoundError(err) { fmt.Printf("✅ Cleanup verified - app no longer exists\n") } else if err != nil { fmt.Printf("✅ Cleanup appears successful (verification returned: %v)\n", err) @@ -306,7 +388,7 @@ func getEnvOrDefault(key, defaultValue string) string { } // waitForInstanceReady polls the instance status until it's no longer "Creating" or timeout -func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKey edgeconnect.AppInstanceKey, region string, timeout time.Duration) (edgeconnect.AppInstance, error) { +func waitForInstanceReady(ctx context.Context, adapter *edgeconnect.Adapter, instanceKey AppInstanceKey, region string, timeout time.Duration) (AppInstance, error) { timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -318,36 +400,128 @@ func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKe for { select { case <-timeoutCtx.Done(): - return edgeconnect.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout) - + return AppInstance{}, fmt.Errorf("timed out waiting for instance to be ready") 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 := adapter.ShowAppInstance(ctx, region, domainInstanceKey) if err != nil { - // Log error but continue polling - fmt.Printf(" ⚠️ Error checking instance state: %v\n", err) + // Continue polling on transient errors + fmt.Printf(" (polling) transient error: %v\n", err) continue } - fmt.Printf(" 📊 Instance state: %s", instance.State) - if instance.PowerState != "" { - fmt.Printf(" (power: %s)", instance.PowerState) - } - fmt.Printf("\n") - - // Check if instance is ready (not in creating state) - 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) - } else { - // Instance is in some other stable state (not creating) - fmt.Printf(" ✅ Instance reached stable state: %s\n", instance.State) - return instance, nil + // Check for a terminal state + if instance.State != "Creating" && instance.State != "Updating" { + if instance.State == "Ready" { + return fromDomainAppInstance(instance), nil } + return AppInstance{}, fmt.Errorf("instance entered a non-ready terminal state: %s", instance.State) } + fmt.Printf(" (polling) current state: %s...\n", instance.State) } } } + +// The structs below are included to make the example self-contained and runnable. +// In a real application, these would be defined in the `edgeconnect` package. + +type NewAppInput struct { + Region string + App App + RequiredOutboundConnections []SecurityRule +} + +type App struct { + Key AppKey + Deployment string + ImageType string + ImagePath string + DefaultFlavor Flavor + ServerlessConfig interface{} + AllowServerless bool + RequiredOutboundConnections []SecurityRule +} + +type AppKey struct { + Organization string + Name string + Version string +} + +type Flavor struct { + Name string +} + +type SecurityRule struct { + Protocol string + PortRangeMin int + PortRangeMax int + RemoteCIDR string +} + +type NewAppInstanceInput struct { + Region string + AppInst AppInstance +} + +type AppInstance struct { + Key AppInstanceKey + AppKey AppKey + Flavor Flavor + State string + PowerState string +} + +type AppInstanceKey struct { + Organization string + Name string + CloudletKey CloudletKey +} + +type CloudletKey struct { + Organization string + Name string +} + +func toDomainSecurityRules(rules []SecurityRule) []domain.SecurityRule { + domainRules := make([]domain.SecurityRule, len(rules)) + for i, r := range rules { + domainRules[i] = domain.SecurityRule{ + Protocol: r.Protocol, + PortRangeMin: r.PortRangeMin, + PortRangeMax: r.PortRangeMax, + RemoteCIDR: r.RemoteCIDR, + } + } + return domainRules +} + +func fromDomainAppInstance(d *domain.AppInstance) AppInstance { + return AppInstance{ + Key: AppInstanceKey{ + Organization: d.Key.Organization, + Name: d.Key.Name, + CloudletKey: CloudletKey{ + Organization: d.Key.CloudletKey.Organization, + Name: d.Key.CloudletKey.Name, + }, + }, + AppKey: AppKey{ + Organization: d.AppKey.Organization, + Name: d.AppKey.Name, + Version: d.AppKey.Version, + }, + Flavor: Flavor{ + Name: d.Flavor.Name, + }, + State: d.State, + PowerState: d.PowerState, + } +} diff --git a/sdk/examples/deploy_app.go b/sdk/examples/deploy_app.go index b413886..90e2473 100644 --- a/sdk/examples/deploy_app.go +++ b/sdk/examples/deploy_app.go @@ -9,49 +9,52 @@ import ( "log" "net/http" "os" - "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client" ) func main() { // Configure SDK client - baseURL := getEnvOrDefault("EDGEXR_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live") + baseURL := getEnvOrDefault("EDGE_CONNECT_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live") // Support both token-based and username/password authentication - token := getEnvOrDefault("EDGEXR_TOKEN", "") - username := getEnvOrDefault("EDGEXR_USERNAME", "") - password := getEnvOrDefault("EDGEXR_PASSWORD", "") + token := getEnvOrDefault("EDGE_CONNECT_TOKEN", "") + username := getEnvOrDefault("EDGE_CONNECT_USERNAME", "") + password := getEnvOrDefault("EDGE_CONNECT_PASSWORD", "") - var edgeClient *edgeconnect.Client + var client *edgeconnect_client.Client if token != "" { // Use static token authentication fmt.Println("🔐 Using Bearer token authentication") - edgeClient = edgeconnect.NewClient(baseURL, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), - edgeconnect.WithLogger(log.Default()), + client = edgeconnect_client.NewClient(baseURL, + edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)), + edgeconnect_client.WithLogger(log.Default()), ) } else if username != "" && password != "" { // Use username/password authentication (matches existing client pattern) fmt.Println("🔐 Using username/password authentication") - edgeClient = edgeconnect.NewClientWithCredentials(baseURL, username, password, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithLogger(log.Default()), + client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password, + edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect_client.WithLogger(log.Default()), ) } else { - log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD") + log.Fatal("Authentication required: Set either EDGE_CONNECT_TOKEN or both EDGE_CONNECT_USERNAME and EDGE_CONNECT_PASSWORD") } + adapter := edgeconnect.NewAdapter(client) + ctx := context.Background() // Example application to deploy - app := &edgeconnect.NewAppInput{ + app := &NewAppInput{ Region: "EU", - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ + App: App{ + Key: AppKey{ Organization: "edp2", Name: "my-edge-app", Version: "1.0.0", @@ -59,78 +62,115 @@ func main() { Deployment: "docker", ImageType: "ImageTypeDocker", ImagePath: "https://registry-1.docker.io/library/nginx:latest", - DefaultFlavor: edgeconnect.Flavor{Name: "EU.small"}, + DefaultFlavor: Flavor{Name: "EU.small"}, ServerlessConfig: struct{}{}, AllowServerless: false, }, } // Demonstrate app lifecycle - if err := demonstrateAppLifecycle(ctx, edgeClient, app); err != nil { + if err := demonstrateAppLifecycle(ctx, adapter, app); err != nil { log.Fatalf("App lifecycle demonstration failed: %v", err) } fmt.Println("✅ SDK example completed successfully!") } -func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client, input *edgeconnect.NewAppInput) error { +func demonstrateAppLifecycle(ctx context.Context, adapter *edgeconnect.Adapter, input *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 := adapter.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) + fmt.Println(" ✅ App created successfully.") - // Step 2: Query the application - fmt.Println("\n2. Querying application...") - app, err := edgeClient.ShowApp(ctx, appKey, region) - if err != nil { - return fmt.Errorf("failed to show app: %w", err) - } - fmt.Printf("✅ App found: %s/%s v%s (deployment: %s)\n", - app.Key.Organization, app.Key.Name, app.Key.Version, app.Deployment) - - // 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) - if err != nil { - return fmt.Errorf("failed to list apps: %w", err) - } - fmt.Printf("✅ Found %d applications in organization '%s'\n", len(apps), appKey.Organization) - - // Step 4: Clean up - delete the application - fmt.Println("\n4. Cleaning up...") - if err := edgeClient.DeleteApp(ctx, appKey, region); 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) - if err != nil { - if strings.Contains(fmt.Sprintf("%v", err), edgeconnect.ErrResourceNotFound.Error()) { - fmt.Printf("✅ App successfully deleted (not found)\n") - } else { - return fmt.Errorf("unexpected error verifying deletion: %w", err) + // Defer cleanup to ensure the app is deleted even if subsequent steps fail + defer func() { + fmt.Println("\n4. Cleaning up: Deleting application...") + domainAppKey = domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, } - } else { - return fmt.Errorf("app still exists after deletion") + if err := adapter.DeleteApp(ctx, region, domainAppKey); err != nil { + fmt.Printf(" ⚠️ Cleanup failed: %v\n", err) + } else { + fmt.Println(" ✅ App deleted successfully.") + } + }() + + // Step 2: Verify app creation by fetching it + fmt.Println("\n2. Verifying app creation...") + domainAppKey = domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, } + fetchedApp, err := adapter.ShowApp(ctx, region, domainAppKey) + if err != nil { + return fmt.Errorf("failed to get app after creation: %w", err) + } + fmt.Printf(" ✅ Fetched app: %s/%s v%s\n", + fetchedApp.Key.Organization, fetchedApp.Key.Name, fetchedApp.Key.Version) + + // Step 3: (Placeholder for other operations like updating or deploying) + fmt.Println("\n3. Skipping further operations in this example.") return nil } +// Helper to get environment variables or return a default func getEnvOrDefault(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { + if value, exists := os.LookupEnv(key); exists { return value } return defaultValue } + +// The structs below are included to make the example self-contained and runnable. +// In a real application, these would be defined in the `edgeconnect` package. + +type NewAppInput struct { + Region string + App App +} + +type App struct { + Key AppKey + Deployment string + ImageType string + ImagePath string + DefaultFlavor Flavor + ServerlessConfig interface{} + AllowServerless bool +} + +type AppKey struct { + Organization string + Name string + Version string +} + +type Flavor struct { + Name string +} diff --git a/sdk/internal/http/transport.go b/sdk/internal/http/transport.go deleted file mode 100644 index 54e853c..0000000 --- a/sdk/internal/http/transport.go +++ /dev/null @@ -1,219 +0,0 @@ -// ABOUTME: HTTP transport layer with retry logic and request/response handling -// ABOUTME: Provides resilient HTTP communication with context support and error wrapping - -package http - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "math" - "math/rand" - "net/http" - "time" - - "github.com/hashicorp/go-retryablehttp" -) - -// Transport wraps HTTP operations with retry logic and error handling -type Transport struct { - client *retryablehttp.Client - authProvider AuthProvider - logger Logger -} - -// AuthProvider interface for attaching authentication -type AuthProvider interface { - Attach(ctx context.Context, req *http.Request) error -} - -// Logger interface for request/response logging -type Logger interface { - Printf(format string, v ...interface{}) -} - -// RetryOptions configures retry behavior -type RetryOptions struct { - MaxRetries int - InitialDelay time.Duration - MaxDelay time.Duration - Multiplier float64 - RetryableHTTPStatusCodes []int -} - -// NewTransport creates a new HTTP transport with retry capabilities -func NewTransport(opts RetryOptions, auth AuthProvider, logger Logger) *Transport { - client := retryablehttp.NewClient() - - // Configure retry policy - client.RetryMax = opts.MaxRetries - client.RetryWaitMin = opts.InitialDelay - client.RetryWaitMax = opts.MaxDelay - - // Custom retry policy that considers both network errors and HTTP status codes - client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) { - // Default retry for network errors - if err != nil { - return true, nil - } - - // Check if status code is retryable - if resp != nil { - for _, code := range opts.RetryableHTTPStatusCodes { - if resp.StatusCode == code { - return true, nil - } - } - } - - return false, nil - } - - // Custom backoff with jitter - client.Backoff = func(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration { - mult := math.Pow(opts.Multiplier, float64(attemptNum)) - sleep := time.Duration(mult) * min - if sleep > max { - sleep = max - } - // Add jitter - jitter := time.Duration(rand.Float64() * float64(sleep) * 0.1) - return sleep + jitter - } - - // Disable default logging if no logger provided - if logger == nil { - client.Logger = nil - } - - return &Transport{ - client: client, - authProvider: auth, - logger: logger, - } -} - -// Call executes an HTTP request with retry logic and returns typed response -func (t *Transport) Call(ctx context.Context, method, url string, body interface{}) (*http.Response, error) { - var reqBody io.Reader - - // Marshal request body if provided - if body != nil { - jsonData, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) - } - reqBody = bytes.NewReader(jsonData) - } - - // Create retryable request - req, err := retryablehttp.NewRequestWithContext(ctx, method, url, reqBody) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Set headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - // Add authentication - if t.authProvider != nil { - if err := t.authProvider.Attach(ctx, req.Request); err != nil { - return nil, fmt.Errorf("failed to attach auth: %w", err) - } - } - - // Log request - if t.logger != nil { - t.logger.Printf("HTTP %s %s", method, url) - t.logger.Printf("BODY %s", reqBody) - } - - // Execute request - resp, err := t.client.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - - // Log response - if t.logger != nil { - t.logger.Printf("HTTP %s %s -> %d", method, url, resp.StatusCode) - } - - return resp, nil -} - -// CallJSON executes a request and unmarshals the response into a typed result -func (t *Transport) CallJSON(ctx context.Context, method, url string, body interface{}, result interface{}) (*http.Response, error) { - resp, err := t.Call(ctx, method, url, body) - if err != nil { - return resp, err - } - defer resp.Body.Close() - - // Read response body - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return resp, fmt.Errorf("failed to read response body: %w", err) - } - - // For error responses, don't try to unmarshal into result type - if resp.StatusCode >= 400 { - return resp, &HTTPError{ - StatusCode: resp.StatusCode, - Status: resp.Status, - Body: respBody, - } - } - - // Unmarshal successful response - if result != nil && len(respBody) > 0 { - if err := json.Unmarshal(respBody, result); err != nil { - return resp, fmt.Errorf("failed to unmarshal response: %w", err) - } - } - - return resp, nil -} - -// HTTPError represents an HTTP error response -type HTTPError struct { - StatusCode int `json:"status_code"` - Status string `json:"status"` - Body []byte `json:"-"` -} - -func (e *HTTPError) Error() string { - if len(e.Body) > 0 { - return fmt.Sprintf("HTTP %d %s: %s", e.StatusCode, e.Status, string(e.Body)) - } - return fmt.Sprintf("HTTP %d %s", e.StatusCode, e.Status) -} - -// IsRetryable returns true if the error indicates a retryable condition -func (e *HTTPError) IsRetryable() bool { - return e.StatusCode >= 500 || e.StatusCode == 429 || e.StatusCode == 408 -} - -// ParseJSONLines parses streaming JSON response line by line -func ParseJSONLines(body io.Reader, callback func([]byte) error) error { - decoder := json.NewDecoder(body) - - for { - var raw json.RawMessage - if err := decoder.Decode(&raw); err != nil { - if err == io.EOF { - break - } - return fmt.Errorf("failed to decode JSON line: %w", err) - } - - if err := callback(raw); err != nil { - return err - } - } - - return nil -}