Compare commits

...
Sign in to create a new pull request.

16 commits

Author SHA1 Message Date
488fe430fb fix(apply): fixed client logic ... responses are now filtered as before 2025-10-09 17:05:27 +02:00
00487bec7c fix(apply): first hexagonal refactoring mismatched AppTypes 2025-10-09 13:58:45 +02:00
054b1c91fc fix(refactor): added binaries to git ignore 2025-10-09 11:10:51 +02:00
5918ba5db6 fix(refactor): aligned all env vars 2025-10-09 11:08:19 +02:00
5ac67a224d fix: resolve all golangci-lint errors
- Fix errcheck errors by properly handling resp.Body.Close() return values
- Fix staticcheck ST1005 errors by uncapitalizing error messages
- Remove unused orgName variable
- Wrap all deferred Close() calls in anonymous functions to handle errors
2025-10-09 10:54:14 +02:00
1c13c93512 refactor(arch): Relocate domain and ports packages
Moves the `domain` and `ports` packages from `internal/core` to `internal`.

This refactoring simplifies the directory structure by elevating the core architectural concepts of domain and ports to the top level of the `internal` directory. The `core` directory is now removed as its only purpose was to house these two packages.

All import paths across the project have been updated to reflect this change.
2025-10-09 01:16:31 +02:00
a987e42ad6 refactor(domain): Decompose domain.go into individual entity files
Decomposes the monolithic `internal/core/domain/domain.go` file into separate files for each domain entity (`app.go`, `app_instance.go`, `cloudlet.go`, `flavor.go`).

- `SecurityRule` struct moved into `app.go` as it is a value object specific to the App entity.
- `Location` struct moved into `cloudlet.go` as it is a value object specific to the Cloudlet entity.
- `Flavor` remains a separate file as it is a shared entity used by App, AppInstance, and Cloudlet.

This refactoring improves modularity and makes the domain model easier to navigate and understand.
2025-10-09 01:09:36 +02:00
8d6f51978d feat(organization): WiP - Add organization management
Adds the core functionality for managing organizations, including the domain, application service, repository, and CLI adapter.

The `organization` command and its subcommands (`create`, `show`, `update`, `delete`) are now available.

Note: The `show` command is currently not working as expected due to an API issue where it does not find the newly created organization. This is marked as a work in progress.
2025-10-09 01:01:56 +02:00
7b062612f5 refactor(arch): Separate infrastructure from driven adapter
This commit introduces a significant architectural refactoring to decouple the driven adapter from low-level infrastructure concerns, adhering more strictly to the principles of Hexagonal Architecture.

Problem:
The driven adapter in `internal/adapters/driven/edgeconnect` was responsible for both adapting data structures and handling direct HTTP communication, authentication, and request/response logic. This violated the separation of concerns, making the adapter difficult to test and maintain.

Solution:
A new infrastructure layer has been created at `internal/infrastructure`. This layer now contains all the low-level details of interacting with the EdgeConnect API.

Key Changes:
- **New Infrastructure Layer:** Created `internal/infrastructure` to house components that connect to external systems.
- **Generic HTTP Client:** A new, generic `edgeconnect_client` was created in `internal/infrastructure/edgeconnect_client`. It is responsible for authentication, making HTTP requests, and handling raw responses. It has no knowledge of the application's domain models.
- **Config & Transport Moved:** The `config` and `http` (now `transport`) packages were moved into the infrastructure layer, as they are details of how the application is configured and communicates.
- **Consolidated Driven Adapter:** The logic from the numerous old adapter files (`apps.go`, `cloudlet.go`, etc.) has been consolidated into a single, true adapter at `internal/adapters/driven/edgeconnect/adapter.go`.
- **Clear Responsibility:** The new `adapter.go` is now solely responsible for:
  1. Implementing the driven port (repository) interfaces.
  2. Translating domain models into the data structures required by the `edgeconnect_client`.
  3. Calling the `edgeconnect_client` to perform the API operations.
  4. Translating the results back into domain models.
- **Updated Dependency Injection:** The application's entry point (`cmd/cli/main.go`) has been updated to construct and inject dependencies according to the new architecture: `infra_client` -> `adapter` -> `service` -> `cli_command`.
- **SDK & Apply Command:** The SDK examples and the `apply` command have been updated to use the new adapter and its repository methods, removing all direct client instantiation.
2025-10-09 00:47:45 +02:00
f1ee439c61 refactor: structure core logic by application use cases
Restructures the internal business logic from a generic `services` package to a use-case-driven design under `internal/application`.

Each primary function of the application (`app`, `instance`, `cloudlet`, `apply`) now resides in its own package. This clarifies the architecture and makes it easier to navigate and extend.

- Moved service implementations to `internal/application/<usecase>/`.
- Kept ports and domain models in `internal/core/`.
- Updated `main.go` and CLI adapters to reflect the new paths.
- Added missing `RefreshAppInstance` method to satisfy the service interface.
- Verified the change with a full build and test run.
2025-10-09 00:00:51 +02:00
19a9807499 fix: resolve all 27 golangci-lint issues with comprehensive error handling
🔧 Code Quality Improvements:
- Complete errcheck compliance (24 issues → 0)
- staticcheck optimizations (2 issues → 0)
- Unused code cleanup (1 issue → 0)
- Production-ready error handling across codebase

📦 Production Code Fixes (Priority 1):
- resp.Body.Close(): Proper defer functions with error logging
- cmd.MarkFlagRequired(): Panic on setup-critical flag errors
- viper.BindPFlag/BindEnv(): Panic on configuration binding failures
- file.Close(): Warning logs for file handling errors
- fmt.Scanln/cmd.Usage(): Graceful error handling in CLI

🧪 Test Code Fixes (Priority 2):
- w.Write(): Error checking in all HTTP mock servers
- json.NewEncoder().Encode(): Proper error handling in test helpers
- Robust test infrastructure without silent failures

 Performance & Readability (staticcheck):
- if-else chains → tagged switch statements in planner.go
- Empty branch elimination with meaningful error logging
- Import cleanup after unused function removal

🗂️ Code Organization:
- Removed unused createStreamingJSONServer helper function
- Clean imports without unused dependencies
- Consistent error handling patterns across adapters

 Quality Assurance:
- make lint: 27 issues → 0 issues
- All tests passing with robust error handling
- Production-ready error management and logging
- Enhanced code maintainability and debugging

🎯 Impact:
- Eliminates resource leaks from unclosed HTTP bodies
- Prevents silent failures in CLI setup and configuration
- Improves debugging with comprehensive error logging
- Enhances test reliability and error visibility
2025-10-08 18:55:31 +02:00
8e2e61d61e feat: implement dependency injection with proper hexagonal architecture
 Features:
- Simple dependency inversion following SOLID principles
- Clean constructor injection without complex DI containers
- Proper hexagonal architecture with driving/driven separation
- Presentation layer moved to cmd/cli for correct application structure

🏗️ Architecture Changes:
- Driving Adapters (Inbound): internal/adapters/driving/cli/
- Driven Adapters (Outbound): internal/adapters/driven/edgeconnect/
- Core Services: Dependency-injected via interface parameters
- main.go relocated from root to cmd/cli/main.go

📦 Application Flow:
1. cmd/cli/main.go - Entry point and dependency wiring
   └── Creates EdgeConnect client based on environment
   └── Instantiates services with injected repositories
   └── Executes CLI with properly wired dependencies

2. internal/adapters/driving/cli/ - User interface layer
   └── Receives user commands and input validation
   └── Delegates to core services via driving ports
   └── Handles presentation logic and output formatting

3. internal/core/services/ - Business logic layer
   └── NewAppService(appRepo, instanceRepo) - Constructor injection
   └── NewAppInstanceService(instanceRepo) - Interface dependencies
   └── NewCloudletService(cloudletRepo) - Clean separation

4. internal/adapters/driven/edgeconnect/ - Infrastructure layer
   └── Implements repository interfaces for external API
   └── Handles HTTP communication and data persistence
   └── Provides concrete implementations of driven ports

🔧 Build & Deployment:
- CLI Binary: make build → bin/edge-connect-cli
- Usage: ./bin/edge-connect-cli --help
- Tests: make test (all passing)
- Clean: make clean (updated paths)

💡 Benefits:
- Simple and maintainable dependency management
- Testable architecture with clear boundaries
- SOLID principles compliance without overengineering
- Proper separation of concerns in hexagonal structure
2025-10-08 18:15:26 +02:00
2625a58691 feat: implement unified domain error handling system
Addresses Verbesserungspotential 2 (Error Handling uneinheitlich) by introducing
a comprehensive, structured error handling approach across all architectural layers.

## New Domain Error System
- Add ErrorCode enum with 15 semantic error types (NOT_FOUND, VALIDATION_FAILED, etc.)
- Implement DomainError struct with operation context, resource identifiers, and regions
- Create resource-specific error constructors (NewAppError, NewInstanceError, NewCloudletError)
- Add utility functions for error type checking (IsNotFoundError, IsValidationError, etc.)

## Service Layer Enhancements
- Replace generic fmt.Errorf with structured domain errors in all services
- Add comprehensive validation functions for App, AppInstance, and Cloudlet entities
- Implement business logic validation with meaningful error context
- Ensure consistent error semantics across app_service, instance_service, cloudlet_service

## Adapter Layer Updates
- Update EdgeConnect adapters to use domain errors instead of error constants
- Enhance CLI adapter with domain-specific error checking for better UX
- Fix SDK examples to use new IsNotFoundError() approach
- Maintain backward compatibility where possible

## Test Coverage
- Add comprehensive error_test.go with 100% coverage of new error system
- Update existing adapter tests to validate domain error types
- All tests passing with proper error type assertions

## Benefits
-  Consistent error handling across all architectural layers
-  Rich error context with operation, resource, and region information
-  Type-safe error checking with semantic error codes
-  Better user experience with domain-specific error messages
-  Maintainable centralized error definitions
-  Full hexagonal architecture compliance

Files modified: 12 files updated, 2 new files added
Tests: All passing (29+ test cases with enhanced error validation)
2025-10-08 16:52:36 +02:00
7b359f81e3 chore(): unique go version 1.25, fixes 'make test-coverage' error 2025-10-08 16:49:31 +02:00
e72c81bc43 fix(test): fixed by ai all tests after refactoring 2025-10-08 13:35:49 +02:00
43d8f277a6 feat(arch) added hexagonal arch impl done with ai 2025-10-08 12:55:53 +02:00
67 changed files with 4789 additions and 4301 deletions

4
.gitignore vendored
View file

@ -2,6 +2,10 @@ edge-connect
# Added by goreleaser init: # Added by goreleaser init:
dist/ dist/
# ignore binaries
main
bin/
### direnv ### ### direnv ###
.direnv .direnv
.envrc .envrc

View file

@ -13,17 +13,17 @@ test:
# Run tests with coverage # Run tests with coverage
test-coverage: test-coverage:
go test -v -coverprofile=coverage.out ./... GOTOOLCHAIN=go1.25.1 go test -v -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html GOTOOLCHAIN=go1.25.1 go tool cover -html=coverage.out -o coverage.html
# Build the CLI # Build the CLI
build: build:
go build -o bin/edge-connect . go build -o bin/edge-connect-cli ./cmd/cli
# Clean generated files and build artifacts # Clean generated files and build artifacts
clean: clean:
rm -f sdk/client/types_generated.go rm -f sdk/client/types_generated.go
rm -f bin/edge-connect rm -f bin/edge-connect-cli
rm -f coverage.out coverage.html rm -f coverage.out coverage.html
# Lint the code # Lint the code

56
cmd/cli/main.go Normal file
View file

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

View file

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

17
devbox.json Normal file
View file

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

105
devbox.lock Normal file
View file

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

View file

@ -5,7 +5,7 @@
outputs = inputs: outputs = inputs:
let 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" ]; supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forEachSupportedSystem = f: inputs.nixpkgs.lib.genAttrs supportedSystems (system: f { forEachSupportedSystem = f: inputs.nixpkgs.lib.genAttrs supportedSystems (system: f {
@ -17,7 +17,7 @@
in in
{ {
overlays.default = final: prev: { overlays.default = final: prev: {
go = final."go_1_${toString goVersion}"; go = final."go_${goVersion}";
}; };
devShells = forEachSupportedSystem ({ pkgs }: { devShells = forEachSupportedSystem ({ pkgs }: {

View file

@ -0,0 +1,76 @@
# Proposal: Refactor to Hexagonal Architecture
This document proposes a refactoring of the `edge-connect-client` project to a Hexagonal Architecture (also known as Ports and Adapters). This will improve the project's maintainability, testability, and flexibility.
## Current Architecture
The current project structure is a mix of concerns. The `cmd` package contains both CLI handling and business logic, the `sdk` package is a client for the EdgeXR API, and the `internal` package contains some business logic and configuration handling. This makes it difficult to test the business logic in isolation and to adapt the application to different use cases.
## Proposed Hexagonal Architecture
The hexagonal architecture separates the application's core business logic from the outside world. The core communicates with the outside world through ports (interfaces), which are implemented by adapters.
Here is the proposed directory structure:
```
.
├── cmd/
│ └── main.go
├── internal/
│ ├── core/
│ │ ├── domain/
│ │ │ ├── app.go
│ │ │ └── instance.go
│ │ ├── ports/
│ │ │ ├── driven/
│ │ │ │ ├── app_repository.go
│ │ │ │ └── instance_repository.go
│ │ │ └── driving/
│ │ │ ├── app_service.go
│ │ │ └── instance_service.go
│ │ └── services/
│ │ ├── app_service.go
│ │ └── instance_service.go
│ └── adapters/
│ ├── cli/
│ │ ├── app.go
│ │ └── instance.go
│ └── edgeconnect/
│ ├── app.go
│ └── instance.go
├── go.mod
└── go.sum
```
### Core
* `internal/core/domain`: Contains the core domain objects (e.g., `App`, `AppInstance`). These are plain Go structs with no external dependencies.
* `internal/core/ports`: Defines the interfaces for communication with the outside world.
* `driving`: Interfaces for the services offered by the application (e.g., `AppService`, `InstanceService`).
* `driven`: Interfaces for the services the application needs (e.g., `AppRepository`, `InstanceRepository`).
* `internal/core/services`: Implements the `driving` port interfaces. This is where the core business logic resides.
### Adapters
* `internal/adapters/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.

View file

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

View file

@ -1,18 +1,46 @@
package cmd package cli
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"os" "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/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 ( var (
organization string organization string
appName string appName string
@ -20,58 +48,6 @@ var (
region string 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{ var appCmd = &cobra.Command{
Use: "app", Use: "app",
Short: "Manage Edge Connect applications", Short: "Manage Edge Connect applications",
@ -82,19 +58,15 @@ var createAppCmd = &cobra.Command{
Use: "create", Use: "create",
Short: "Create a new Edge Connect application", Short: "Create a new Edge Connect application",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() app := &domain.App{
input := &edgeconnect.NewAppInput{ Key: domain.AppKey{
Region: region,
App: edgeconnect.App{
Key: edgeconnect.AppKey{
Organization: organization, Organization: organization,
Name: appName, Name: appName,
Version: appVersion, Version: appVersion,
}, },
},
} }
err := c.CreateApp(context.Background(), input) err := services.AppService.CreateApp(context.Background(), region, app)
if err != nil { if err != nil {
fmt.Printf("Error creating app: %v\n", err) fmt.Printf("Error creating app: %v\n", err)
os.Exit(1) os.Exit(1)
@ -107,15 +79,24 @@ var showAppCmd = &cobra.Command{
Use: "show", Use: "show",
Short: "Show details of an Edge Connect application", Short: "Show details of an Edge Connect application",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() appKey := domain.AppKey{
appKey := edgeconnect.AppKey{
Organization: organization, Organization: organization,
Name: appName, Name: appName,
Version: appVersion, Version: appVersion,
} }
app, err := c.ShowApp(context.Background(), appKey, region) app, err := services.AppService.ShowApp(context.Background(), region, appKey)
if err != nil { 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) fmt.Printf("Error showing app: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@ -127,14 +108,13 @@ var listAppsCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List Edge Connect applications", Short: "List Edge Connect applications",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() appKey := domain.AppKey{
appKey := edgeconnect.AppKey{
Organization: organization, Organization: organization,
Name: appName, Name: appName,
Version: appVersion, Version: appVersion,
} }
apps, err := c.ShowApps(context.Background(), appKey, region) apps, err := services.AppService.ShowApps(context.Background(), region, appKey)
if err != nil { if err != nil {
fmt.Printf("Error listing apps: %v\n", err) fmt.Printf("Error listing apps: %v\n", err)
os.Exit(1) os.Exit(1)
@ -150,14 +130,13 @@ var deleteAppCmd = &cobra.Command{
Use: "delete", Use: "delete",
Short: "Delete an Edge Connect application", Short: "Delete an Edge Connect application",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() appKey := domain.AppKey{
appKey := edgeconnect.AppKey{
Organization: organization, Organization: organization,
Name: appName, Name: appName,
Version: appVersion, Version: appVersion,
} }
err := c.DeleteApp(context.Background(), appKey, region) err := services.AppService.DeleteApp(context.Background(), region, appKey)
if err != nil { if err != nil {
fmt.Printf("Error deleting app: %v\n", err) fmt.Printf("Error deleting app: %v\n", err)
os.Exit(1) os.Exit(1)
@ -177,12 +156,18 @@ func init() {
cmd.Flags().StringVarP(&appName, "name", "n", "", "application name") cmd.Flags().StringVarP(&appName, "name", "n", "", "application name")
cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version") cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)") cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)")
cmd.MarkFlagRequired("org") if err := cmd.MarkFlagRequired("org"); err != nil {
cmd.MarkFlagRequired("region") 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 // Add required name flag for specific commands
for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} { 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))
}
} }
} }

View file

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

View file

@ -1,17 +1,21 @@
// ABOUTME: CLI command for declarative deployment of EdgeConnect applications from YAML configuration // 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 // ABOUTME: Integrates config parser, deployment planner, and resource manager for complete deployment workflow
package cmd package cli
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "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" "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) { Run: func(cmd *cobra.Command, args []string) {
if configFile == "" { if configFile == "" {
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n") 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) 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) fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name)
// Step 3: Create EdgeConnect client // 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 var client *edgeconnect_client.Client
planner := apply.NewPlanner(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 // Step 5: Generate deployment plan
fmt.Println("🔍 Analyzing current state and generating 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 // Step 9: Execute deployment
fmt.Println("\n🚀 Starting 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) deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
if err != nil { if err != nil {
return fmt.Errorf("deployment failed: %w", err) return fmt.Errorf("deployment failed: %w", err)
@ -156,7 +187,10 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error {
func confirmDeployment() bool { func confirmDeployment() bool {
fmt.Print("Do you want to proceed? (yes/no): ") fmt.Print("Do you want to proceed? (yes/no): ")
var response string 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 { switch response {
case "yes", "y", "YES", "Y": 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() { func init() {
rootCmd.AddCommand(applyCmd) rootCmd.AddCommand(applyCmd)
@ -173,5 +214,7 @@ func init() {
applyCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without applying them") 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.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))
}
} }

View file

@ -1,11 +1,11 @@
package cmd package cli
import ( import (
"context" "context"
"fmt" "fmt"
"os" "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" "github.com/spf13/cobra"
) )
@ -26,30 +26,26 @@ var createInstanceCmd = &cobra.Command{
Use: "create", Use: "create",
Short: "Create a new Edge Connect application instance", Short: "Create a new Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() appInst := &domain.AppInstance{
input := &edgeconnect.NewAppInstanceInput{ Key: domain.AppInstanceKey{
Region: region,
AppInst: edgeconnect.AppInstance{
Key: edgeconnect.AppInstanceKey{
Organization: organization, Organization: organization,
Name: instanceName, Name: instanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: domain.CloudletKey{
Organization: cloudletOrg, Organization: cloudletOrg,
Name: cloudletName, Name: cloudletName,
}, },
}, },
AppKey: edgeconnect.AppKey{ AppKey: domain.AppKey{
Organization: organization, Organization: organization,
Name: appName, Name: appName,
Version: appVersion, Version: appVersion,
}, },
Flavor: edgeconnect.Flavor{ Flavor: domain.Flavor{
Name: flavorName, Name: flavorName,
}, },
},
} }
err := c.CreateAppInstance(context.Background(), input) err := services.InstanceService.CreateAppInstance(context.Background(), region, appInst)
if err != nil { if err != nil {
fmt.Printf("Error creating app instance: %v\n", err) fmt.Printf("Error creating app instance: %v\n", err)
os.Exit(1) os.Exit(1)
@ -62,17 +58,16 @@ var showInstanceCmd = &cobra.Command{
Use: "show", Use: "show",
Short: "Show details of an Edge Connect application instance", Short: "Show details of an Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() instanceKey := domain.AppInstanceKey{
instanceKey := edgeconnect.AppInstanceKey{
Organization: organization, Organization: organization,
Name: instanceName, Name: instanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: domain.CloudletKey{
Organization: cloudletOrg, Organization: cloudletOrg,
Name: cloudletName, Name: cloudletName,
}, },
} }
instance, err := c.ShowAppInstance(context.Background(), instanceKey, region) instance, err := services.InstanceService.ShowAppInstance(context.Background(), region, instanceKey)
if err != nil { if err != nil {
fmt.Printf("Error showing app instance: %v\n", err) fmt.Printf("Error showing app instance: %v\n", err)
os.Exit(1) os.Exit(1)
@ -85,17 +80,16 @@ var listInstancesCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List Edge Connect application instances", Short: "List Edge Connect application instances",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() instanceKey := domain.AppInstanceKey{
instanceKey := edgeconnect.AppInstanceKey{
Organization: organization, Organization: organization,
Name: instanceName, Name: instanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: domain.CloudletKey{
Organization: cloudletOrg, Organization: cloudletOrg,
Name: cloudletName, Name: cloudletName,
}, },
} }
instances, err := c.ShowAppInstances(context.Background(), instanceKey, region) instances, err := services.InstanceService.ShowAppInstances(context.Background(), region, instanceKey)
if err != nil { if err != nil {
fmt.Printf("Error listing app instances: %v\n", err) fmt.Printf("Error listing app instances: %v\n", err)
os.Exit(1) os.Exit(1)
@ -111,17 +105,16 @@ var deleteInstanceCmd = &cobra.Command{
Use: "delete", Use: "delete",
Short: "Delete an Edge Connect application instance", Short: "Delete an Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() instanceKey := domain.AppInstanceKey{
instanceKey := edgeconnect.AppInstanceKey{
Organization: organization, Organization: organization,
Name: instanceName, Name: instanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: domain.CloudletKey{
Organization: cloudletOrg, Organization: cloudletOrg,
Name: cloudletName, Name: cloudletName,
}, },
} }
err := c.DeleteAppInstance(context.Background(), instanceKey, region) err := services.InstanceService.DeleteAppInstance(context.Background(), region, instanceKey)
if err != nil { if err != nil {
fmt.Printf("Error deleting app instance: %v\n", err) fmt.Printf("Error deleting app instance: %v\n", err)
os.Exit(1) os.Exit(1)
@ -143,17 +136,21 @@ func init() {
cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)") cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)")
cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)") cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)")
cmd.MarkFlagRequired("org") for _, flag := range []string{"org", "name", "cloudlet", "cloudlet-org", "region"} {
cmd.MarkFlagRequired("name") if err := cmd.MarkFlagRequired(flag); err != nil {
cmd.MarkFlagRequired("cloudlet") panic(fmt.Sprintf("Failed to mark '%s' flag as required: %v", flag, err))
cmd.MarkFlagRequired("cloudlet-org") }
cmd.MarkFlagRequired("region") }
} }
// Add additional flags for create command // Add additional flags for create command
createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)") createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)")
createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version") createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)") createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)")
createInstanceCmd.MarkFlagRequired("app") if err := createInstanceCmd.MarkFlagRequired("app"); err != nil {
createInstanceCmd.MarkFlagRequired("flavor") 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))
}
} }

View file

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

View file

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

View file

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

View file

@ -7,8 +7,9 @@ import (
"fmt" "fmt"
"time" "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/sdk/edgeconnect" "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 // ResourceManagerInterface defines the interface for resource management
@ -25,7 +26,8 @@ type ResourceManagerInterface interface {
// EdgeConnectResourceManager implements resource management for EdgeConnect // EdgeConnectResourceManager implements resource management for EdgeConnect
type EdgeConnectResourceManager struct { type EdgeConnectResourceManager struct {
client EdgeConnectClientInterface appRepo driven.AppRepository
appInstRepo driven.AppInstanceRepository
parallelLimit int parallelLimit int
rollbackOnFail bool rollbackOnFail bool
logger Logger logger Logger
@ -66,14 +68,15 @@ func DefaultResourceManagerOptions() ResourceManagerOptions {
} }
// NewResourceManager creates a new EdgeConnect resource manager // 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() options := DefaultResourceManagerOptions()
for _, opt := range opts { for _, opt := range opts {
opt(&options) opt(&options)
} }
return &EdgeConnectResourceManager{ return &EdgeConnectResourceManager{
client: client, appRepo: appRepo,
appInstRepo: appInstRepo,
parallelLimit: options.ParallelLimit, parallelLimit: options.ParallelLimit,
rollbackOnFail: options.RollbackOnFail, rollbackOnFail: options.RollbackOnFail,
logger: options.Logger, logger: options.Logger,
@ -133,7 +136,7 @@ func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan
strategyConfig := rm.strategyConfig strategyConfig := rm.strategyConfig
strategyConfig.ParallelOperations = rm.parallelLimit > 1 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) strategy, err := factory.CreateStrategy(strategyName)
if err != nil { if err != nil {
result := &ExecutionResult{ result := &ExecutionResult{
@ -190,8 +193,8 @@ func (rm *EdgeConnectResourceManager) ValidatePrerequisites(ctx context.Context,
} }
// Validate that we have required client capabilities // Validate that we have required client capabilities
if rm.client == nil { if rm.appRepo == nil || rm.appInstRepo == nil {
return fmt.Errorf("EdgeConnect client is not configured") return fmt.Errorf("repositories are not configured")
} }
rm.logf("Prerequisites validation passed") rm.logf("Prerequisites validation passed")
@ -250,13 +253,13 @@ func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context,
// rollbackApp deletes an application that was created // rollbackApp deletes an application that was created
func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error { func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
appKey := edgeconnect.AppKey{ appKey := domain.AppKey{
Organization: plan.AppAction.Desired.Organization, Organization: plan.AppAction.Desired.Organization,
Name: plan.AppAction.Desired.Name, Name: plan.AppAction.Desired.Name,
Version: plan.AppAction.Desired.Version, 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 // 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 // Find the instance action to get the details
for _, instanceAction := range plan.InstanceActions { for _, instanceAction := range plan.InstanceActions {
if instanceAction.InstanceName == action.Target { if instanceAction.InstanceName == action.Target {
instanceKey := edgeconnect.AppInstanceKey{ instanceKey := domain.AppInstanceKey{
Organization: plan.AppAction.Desired.Organization, Organization: plan.AppAction.Desired.Organization,
Name: instanceAction.InstanceName, Name: instanceAction.InstanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: domain.CloudletKey{
Organization: instanceAction.Target.CloudletOrg, Organization: instanceAction.Target.CloudletOrg,
Name: instanceAction.Target.CloudletName, 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) return fmt.Errorf("instance action not found for rollback: %s", action.Target)

View file

@ -10,8 +10,8 @@ import (
"testing" "testing"
"time" "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/sdk/edgeconnect" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -19,36 +19,61 @@ import (
// MockResourceClient extends MockEdgeConnectClient with resource management methods // MockResourceClient extends MockEdgeConnectClient with resource management methods
type MockResourceClient struct { type MockResourceClient struct {
MockEdgeConnectClient mock.Mock
} }
func (m *MockResourceClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { func (m *MockResourceClient) CreateApp(ctx context.Context, region string, app *domain.App) error {
args := m.Called(ctx, input) args := m.Called(ctx, region, app)
return args.Error(0) return args.Error(0)
} }
func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error { func (m *MockResourceClient) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) {
args := m.Called(ctx, input) 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) return args.Error(0)
} }
func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error { func (m *MockResourceClient) UpdateApp(ctx context.Context, region string, app *domain.App) error {
args := m.Called(ctx, appKey, region) args := m.Called(ctx, region, app)
return args.Error(0) return args.Error(0)
} }
func (m *MockResourceClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error { func (m *MockResourceClient) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
args := m.Called(ctx, input) args := m.Called(ctx, region, appInst)
return args.Error(0) return args.Error(0)
} }
func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error { func (m *MockResourceClient) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) {
args := m.Called(ctx, input) 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) return args.Error(0)
} }
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error { func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
args := m.Called(ctx, instanceKey, region) 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) return args.Error(0)
} }
@ -62,8 +87,9 @@ func (l *TestLogger) Printf(format string, v ...interface{}) {
} }
func TestNewResourceManager(t *testing.T) { func TestNewResourceManager(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
manager := NewResourceManager(mockClient) mockAppInstRepo := &MockResourceClient{}
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
assert.NotNil(t, manager) assert.NotNil(t, manager)
assert.IsType(t, &EdgeConnectResourceManager{}, manager) assert.IsType(t, &EdgeConnectResourceManager{}, manager)
@ -78,10 +104,11 @@ func TestDefaultResourceManagerOptions(t *testing.T) {
} }
func TestWithOptions(t *testing.T) { func TestWithOptions(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
mockAppInstRepo := &MockResourceClient{}
logger := &TestLogger{} logger := &TestLogger{}
manager := NewResourceManager(mockClient, manager := NewResourceManager(mockAppRepo, mockAppInstRepo,
WithParallelLimit(10), WithParallelLimit(10),
WithRollbackOnFail(false), WithRollbackOnFail(false),
WithLogger(logger), WithLogger(logger),
@ -177,17 +204,18 @@ func createTestStrategyConfig() StrategyConfig {
} }
func TestApplyDeploymentSuccess(t *testing.T) { func TestApplyDeploymentSuccess(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
mockAppInstRepo := &MockResourceClient{}
logger := &TestLogger{} logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan() plan := createTestDeploymentPlan()
config := createTestManagerConfig(t) config := createTestManagerConfig(t)
// Mock successful operations // 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) 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) Return(nil)
ctx := context.Background() ctx := context.Background()
@ -204,20 +232,22 @@ func TestApplyDeploymentSuccess(t *testing.T) {
// Check that operations were logged // Check that operations were logged
assert.Greater(t, len(logger.messages), 0) assert.Greater(t, len(logger.messages), 0)
mockClient.AssertExpectations(t) mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
} }
func TestApplyDeploymentAppFailure(t *testing.T) { func TestApplyDeploymentAppFailure(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
mockAppInstRepo := &MockResourceClient{}
logger := &TestLogger{} logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan() plan := createTestDeploymentPlan()
config := createTestManagerConfig(t) config := createTestManagerConfig(t)
// Mock app creation failure - deployment should stop here // Mock app creation failure - deployment should stop here
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) Return(fmt.Errorf("Server error"))
ctx := context.Background() ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content") 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.Len(t, result.FailedActions, 1)
assert.Contains(t, err.Error(), "Server error") assert.Contains(t, err.Error(), "Server error")
mockClient.AssertExpectations(t) mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
} }
func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
mockAppInstRepo := &MockResourceClient{}
logger := &TestLogger{} logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig())) manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan() plan := createTestDeploymentPlan()
config := createTestManagerConfig(t) config := createTestManagerConfig(t)
// Mock successful app creation but failed instance creation // 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) Return(nil)
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")).
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}}) Return(fmt.Errorf("Instance creation failed"))
// Mock rollback operations // 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) Return(nil)
ctx := context.Background() ctx := context.Background()
@ -262,12 +294,14 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
assert.True(t, result.RollbackSuccess) assert.True(t, result.RollbackSuccess)
assert.Contains(t, err.Error(), "failed to create instance") assert.Contains(t, err.Error(), "failed to create instance")
mockClient.AssertExpectations(t) mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
} }
func TestApplyDeploymentNoActions(t *testing.T) { func TestApplyDeploymentNoActions(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
manager := NewResourceManager(mockClient) mockAppInstRepo := &MockResourceClient{}
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
// Create empty plan // Create empty plan
plan := &DeploymentPlan{ plan := &DeploymentPlan{
@ -283,14 +317,15 @@ func TestApplyDeploymentNoActions(t *testing.T) {
require.NotNil(t, result) require.NotNil(t, result)
assert.Contains(t, err.Error(), "deployment plan is empty") assert.Contains(t, err.Error(), "deployment plan is empty")
mockClient.AssertNotCalled(t, "CreateApp") mockAppRepo.AssertNotCalled(t, "CreateApp")
mockClient.AssertNotCalled(t, "CreateAppInstance") mockAppInstRepo.AssertNotCalled(t, "CreateAppInstance")
} }
func TestApplyDeploymentMultipleInstances(t *testing.T) { func TestApplyDeploymentMultipleInstances(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
mockAppInstRepo := &MockResourceClient{}
logger := &TestLogger{} 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 // Create plan with multiple instances
plan := &DeploymentPlan{ plan := &DeploymentPlan{
@ -333,9 +368,9 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
config := createTestManagerConfig(t) config := createTestManagerConfig(t)
// Mock successful operations // 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) 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) Return(nil)
ctx := context.Background() 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.CompletedActions, 3) // 1 app + 2 instances
assert.Len(t, result.FailedActions, 0) assert.Len(t, result.FailedActions, 0)
mockClient.AssertExpectations(t) mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
} }
func TestValidatePrerequisites(t *testing.T) { func TestValidatePrerequisites(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
manager := NewResourceManager(mockClient) mockAppInstRepo := &MockResourceClient{}
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
tests := []struct { tests := []struct {
name string name string
@ -397,9 +434,10 @@ func TestValidatePrerequisites(t *testing.T) {
} }
func TestRollbackDeployment(t *testing.T) { func TestRollbackDeployment(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
mockAppInstRepo := &MockResourceClient{}
logger := &TestLogger{} logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
// Create result with completed actions // Create result with completed actions
plan := createTestDeploymentPlan() plan := createTestDeploymentPlan()
@ -421,24 +459,26 @@ func TestRollbackDeployment(t *testing.T) {
} }
// Mock rollback operations // 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) 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) Return(nil)
ctx := context.Background() ctx := context.Background()
err := manager.RollbackDeployment(ctx, result) err := manager.RollbackDeployment(ctx, result)
require.NoError(t, err) require.NoError(t, err)
mockClient.AssertExpectations(t) mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
// Check rollback was logged // Check rollback was logged
assert.Greater(t, len(logger.messages), 0) assert.Greater(t, len(logger.messages), 0)
} }
func TestRollbackDeploymentFailure(t *testing.T) { func TestRollbackDeploymentFailure(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
manager := NewResourceManager(mockClient) mockAppInstRepo := &MockResourceClient{}
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
plan := createTestDeploymentPlan() plan := createTestDeploymentPlan()
result := &ExecutionResult{ result := &ExecutionResult{
@ -453,15 +493,16 @@ func TestRollbackDeploymentFailure(t *testing.T) {
} }
// Mock rollback failure // Mock rollback failure
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")).
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Delete failed"}}) Return(fmt.Errorf("Delete failed"))
ctx := context.Background() ctx := context.Background()
err := manager.RollbackDeployment(ctx, result) err := manager.RollbackDeployment(ctx, result)
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "rollback encountered") assert.Contains(t, err.Error(), "rollback encountered")
mockClient.AssertExpectations(t) mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
} }
func TestConvertNetworkRules(t *testing.T) { func TestConvertNetworkRules(t *testing.T) {

View file

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

View file

@ -11,22 +11,11 @@ import (
"strings" "strings"
"time" "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/sdk/edgeconnect" "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 // Planner defines the interface for deployment planning
type Planner interface { type Planner interface {
// Plan analyzes the configuration and current state to generate a deployment plan // 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 // EdgeConnectPlanner implements the Planner interface for EdgeConnect
type EdgeConnectPlanner struct { type EdgeConnectPlanner struct {
client EdgeConnectClientInterface appRepo driven.AppRepository
appInstRepo driven.AppInstanceRepository
} }
// NewPlanner creates a new EdgeConnect deployment planner // NewPlanner creates a new EdgeConnect deployment planner
func NewPlanner(client EdgeConnectClientInterface) Planner { func NewPlanner(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) Planner {
return &EdgeConnectPlanner{ 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 // Extract outbound connections from config
if config.Spec.Network != nil { 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 { for i, conn := range config.Spec.Network.OutboundConnections {
desired.OutboundConnections[i] = SecurityRule{ desired.OutboundConnections[i] = domain.SecurityRule{
Protocol: conn.Protocol, Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin, PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax, PortRangeMax: conn.PortRangeMax,
@ -285,13 +276,13 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
timeoutCtx, cancel := context.WithTimeout(ctx, timeout) timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
appKey := edgeconnect.AppKey{ appKey := domain.AppKey{
Organization: desired.Organization, Organization: desired.Organization,
Name: desired.Name, Name: desired.Name,
Version: desired.Version, Version: desired.Version,
} }
app, err := p.client.ShowApp(timeoutCtx, appKey, desired.Region) app, err := p.appRepo.ShowApp(timeoutCtx, desired.Region, appKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -321,9 +312,9 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
} }
// Extract outbound connections from the app // 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 { for i, conn := range app.RequiredOutboundConnections {
current.OutboundConnections[i] = SecurityRule{ current.OutboundConnections[i] = domain.SecurityRule{
Protocol: conn.Protocol, Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin, PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax, PortRangeMax: conn.PortRangeMax,
@ -339,16 +330,16 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire
timeoutCtx, cancel := context.WithTimeout(ctx, timeout) timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
instanceKey := edgeconnect.AppInstanceKey{ instanceKey := domain.AppInstanceKey{
Organization: desired.Organization, Organization: desired.Organization,
Name: desired.Name, Name: desired.Name,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: domain.CloudletKey{
Organization: desired.CloudletOrg, Organization: desired.CloudletOrg,
Name: desired.CloudletName, Name: desired.CloudletName,
}, },
} }
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region) instance, err := p.appInstRepo.ShowAppInstance(timeoutCtx, desired.Region, instanceKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -405,10 +396,10 @@ func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]str
} }
// compareOutboundConnections compares two sets of outbound connections for equality // 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 var changes []string
makeMap := func(rules []SecurityRule) map[string]SecurityRule { makeMap := func(rules []domain.SecurityRule) map[string]domain.SecurityRule {
m := make(map[string]SecurityRule, len(rules)) m := make(map[string]domain.SecurityRule, len(rules))
for _, r := range rules { for _, r := range rules {
key := fmt.Sprintf("%s:%d-%d:%s", key := fmt.Sprintf("%s:%d-%d:%s",
strings.ToLower(r.Protocol), strings.ToLower(r.Protocol),
@ -470,7 +461,12 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string,
if err != nil { if err != nil {
return "", fmt.Errorf("failed to open manifest file: %w", err) 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() hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil { if _, err := io.Copy(hasher, file); err != nil {
@ -505,18 +501,20 @@ func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) ti
var duration time.Duration var duration time.Duration
// App operations // App operations
if plan.AppAction.Type == ActionCreate { switch plan.AppAction.Type {
case ActionCreate:
duration += 30 * time.Second duration += 30 * time.Second
} else if plan.AppAction.Type == ActionUpdate { case ActionUpdate:
duration += 15 * time.Second duration += 15 * time.Second
} }
// Instance operations (can be done in parallel) // Instance operations (can be done in parallel)
instanceDuration := time.Duration(0) instanceDuration := time.Duration(0)
for _, action := range plan.InstanceActions { for _, action := range plan.InstanceActions {
if action.Type == ActionCreate { switch action.Type {
case ActionCreate:
instanceDuration = max(instanceDuration, 2*time.Minute) instanceDuration = max(instanceDuration, 2*time.Minute)
} else if action.Type == ActionUpdate { case ActionUpdate:
instanceDuration = max(instanceDuration, 1*time.Minute) instanceDuration = max(instanceDuration, 1*time.Minute)
} }
} }

View file

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

View file

@ -7,7 +7,8 @@ import (
"fmt" "fmt"
"time" "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 // DeploymentStrategy represents the type of deployment strategy
@ -67,15 +68,17 @@ func DefaultStrategyConfig() StrategyConfig {
// StrategyFactory creates deployment strategy executors // StrategyFactory creates deployment strategy executors
type StrategyFactory struct { type StrategyFactory struct {
config StrategyConfig config StrategyConfig
client EdgeConnectClientInterface appRepo driven.AppRepository
appInstRepo driven.AppInstanceRepository
logger Logger logger Logger
} }
// NewStrategyFactory creates a new strategy factory // 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{ return &StrategyFactory{
config: config, config: config,
client: client, appRepo: appRepo,
appInstRepo: appInstRepo,
logger: logger, logger: logger,
} }
} }
@ -84,7 +87,7 @@ func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig
func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) { func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) {
switch strategy { switch strategy {
case StrategyRecreate: case StrategyRecreate:
return NewRecreateStrategy(f.client, f.config, f.logger), nil return NewRecreateStrategy(f.appRepo, f.appInstRepo, f.config, f.logger), nil
case StrategyBlueGreen: case StrategyBlueGreen:
// TODO: Implement blue-green strategy // TODO: Implement blue-green strategy
return nil, fmt.Errorf("blue-green strategy not yet implemented") return nil, fmt.Errorf("blue-green strategy not yet implemented")

View file

@ -9,21 +9,24 @@ import (
"sync" "sync"
"time" "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/sdk/edgeconnect" "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 // RecreateStrategy implements the recreate deployment strategy
type RecreateStrategy struct { type RecreateStrategy struct {
client EdgeConnectClientInterface appRepo driven.AppRepository
appInstRepo driven.AppInstanceRepository
config StrategyConfig config StrategyConfig
logger Logger logger Logger
} }
// NewRecreateStrategy creates a new recreate strategy executor // 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{ return &RecreateStrategy{
client: client, appRepo: appRepo,
appInstRepo: appInstRepo,
config: config, config: config,
logger: logger, logger: logger,
} }
@ -183,13 +186,13 @@ func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentP
r.logf("Phase 2: Deleting existing application") r.logf("Phase 2: Deleting existing application")
appKey := edgeconnect.AppKey{ appKey := domain.AppKey{
Organization: plan.AppAction.Desired.Organization, Organization: plan.AppAction.Desired.Organization,
Name: plan.AppAction.Desired.Name, Name: plan.AppAction.Desired.Name,
Version: plan.AppAction.Desired.Version, 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{ result.FailedActions = append(result.FailedActions, ActionResult{
Type: ActionDelete, Type: ActionDelete,
Target: plan.AppAction.Desired.Name, 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 { if success {
result.Success = true result.Success = true
result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name) 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) // deleteInstance deletes an instance (reuse existing logic from manager.go)
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) { func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
instanceKey := edgeconnect.AppInstanceKey{ instanceKey := domain.AppInstanceKey{
Organization: action.Desired.Organization, Organization: action.Desired.Organization,
Name: action.InstanceName, Name: action.InstanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: domain.CloudletKey{
Organization: action.Target.CloudletOrg, Organization: action.Target.CloudletOrg,
Name: action.Target.CloudletName, 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 { if err != nil {
return false, fmt.Errorf("failed to delete instance: %w", err) 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) // createInstance creates an instance (extracted from manager.go logic)
func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) { func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
instanceInput := &edgeconnect.NewAppInstanceInput{ appInst := &domain.AppInstance{
Region: action.Target.Region, Key: domain.AppInstanceKey{
AppInst: edgeconnect.AppInstance{
Key: edgeconnect.AppInstanceKey{
Organization: action.Desired.Organization, Organization: action.Desired.Organization,
Name: action.InstanceName, Name: action.InstanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: domain.CloudletKey{
Organization: action.Target.CloudletOrg, Organization: action.Target.CloudletOrg,
Name: action.Target.CloudletName, Name: action.Target.CloudletName,
}, },
}, },
AppKey: edgeconnect.AppKey{ AppKey: domain.AppKey{
Organization: action.Desired.Organization, Organization: action.Desired.Organization,
Name: config.Metadata.Name, Name: config.Metadata.Name,
Version: config.Metadata.AppVersion, Version: config.Metadata.AppVersion,
}, },
Flavor: edgeconnect.Flavor{ Flavor: domain.Flavor{
Name: action.Target.FlavorName, Name: action.Target.FlavorName,
}, },
},
} }
// Create the instance // 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) 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 return true, nil
} }
// updateApplication creates/recreates an application (always uses CreateApp since we delete first) // createApplication 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) { func (r *RecreateStrategy) createApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) {
// Build the app create input - always create since recreate strategy deletes first app := &domain.App{
appInput := &edgeconnect.NewAppInput{ Key: domain.AppKey{
Region: action.Desired.Region,
App: edgeconnect.App{
Key: edgeconnect.AppKey{
Organization: action.Desired.Organization, Organization: action.Desired.Organization,
Name: action.Desired.Name, Name: action.Desired.Name,
Version: action.Desired.Version, Version: action.Desired.Version,
}, },
Deployment: config.GetDeploymentType(), Deployment: config.GetDeploymentType(),
ImageType: "ImageTypeDocker",
ImagePath: config.GetImagePath(), ImagePath: config.GetImagePath(),
AllowServerless: true, AllowServerless: true,
DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, DefaultFlavor: domain.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
ServerlessConfig: struct{}{}, ServerlessConfig: struct{}{},
DeploymentManifest: manifestContent, DeploymentManifest: manifestContent,
DeploymentGenerator: "kubernetes-basic", DeploymentGenerator: "kubernetes-basic",
},
} }
// Add network configuration if specified // Add network configuration if specified
if config.Spec.Network != nil { 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) // 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) return false, fmt.Errorf("failed to create application: %w", err)
} }
@ -497,6 +497,24 @@ func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppActi
return true, nil 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 // logf logs a message if a logger is configured
func (r *RecreateStrategy) logf(format string, v ...interface{}) { func (r *RecreateStrategy) logf(format string, v ...interface{}) {
if r.logger != nil { if r.logger != nil {

View file

@ -0,0 +1,161 @@
// ABOUTME: Core types for EdgeConnect deployment planning and execution
// ABOUTME: Defines data structures for deployment plans, actions, and results
package apply
import (
"fmt"
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/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
}

View file

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

View file

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

View file

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

View file

@ -1,663 +0,0 @@
// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios
// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios
package apply
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// MockEdgeConnectClient is a mock implementation of the EdgeConnect client
type MockEdgeConnectClient struct {
mock.Mock
}
func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) {
args := m.Called(ctx, appKey, region)
if args.Get(0) == nil {
return edgeconnect.App{}, args.Error(1)
}
return args.Get(0).(edgeconnect.App), args.Error(1)
}
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) {
args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil {
return edgeconnect.AppInstance{}, args.Error(1)
}
return args.Get(0).(edgeconnect.AppInstance), args.Error(1)
}
func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error {
args := m.Called(ctx, appKey, region)
return args.Error(0)
}
func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error {
args := m.Called(ctx, instanceKey, region)
return args.Error(0)
}
func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect.AppKey, region string) ([]edgeconnect.App, error) {
args := m.Called(ctx, appKey, region)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]edgeconnect.App), args.Error(1)
}
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) {
args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]edgeconnect.AppInstance), args.Error(1)
}
func TestNewPlanner(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
assert.NotNil(t, planner)
assert.IsType(t, &EdgeConnectPlanner{}, planner)
}
func TestDefaultPlanOptions(t *testing.T) {
opts := DefaultPlanOptions()
assert.False(t, opts.DryRun)
assert.False(t, opts.Force)
assert.False(t, opts.SkipStateCheck)
assert.True(t, opts.ParallelQueries)
assert.Equal(t, 30*time.Second, opts.Timeout)
}
func createTestConfig(t *testing.T) *config.EdgeConnectConfig {
// Create temporary manifest file
tempDir := t.TempDir()
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
require.NoError(t, err)
return &config.EdgeConnectConfig{
Kind: "edgeconnect-deployment",
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "testorg",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{
ManifestFile: manifestFile,
},
InfraTemplate: []config.InfraTemplate{
{
Region: "US",
CloudletOrg: "TestCloudletOrg",
CloudletName: "TestCloudlet",
FlavorName: "small",
},
},
Network: &config.NetworkConfig{
OutboundConnections: []config.OutboundConnection{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
},
},
}
}
func TestPlanNewDeployment(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Mock API calls to return "not found" errors
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
require.NoError(t, result.Error)
plan := result.Plan
assert.Equal(t, "test-app", plan.ConfigName)
assert.Equal(t, ActionCreate, plan.AppAction.Type)
assert.Equal(t, "Application does not exist", plan.AppAction.Reason)
require.Len(t, plan.InstanceActions, 1)
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
assert.Equal(t, "Instance does not exist", plan.InstanceActions[0].Reason)
assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance
assert.False(t, plan.IsEmpty())
mockClient.AssertExpectations(t)
}
func TestPlanExistingDeploymentNoChanges(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Note: We would calculate expected manifest hash here when API supports it
// Mock existing app with same manifest hash and outbound connections
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
existingApp := &edgeconnect.App{
Key: edgeconnect.AppKey{
Organization: "testorg",
Name: "test-app",
Version: "1.0.0",
},
Deployment: "kubernetes",
DeploymentManifest: manifestContent,
RequiredOutboundConnections: []edgeconnect.SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
// Note: Manifest hash tracking would be implemented when API supports annotations
}
// Mock existing instance
existingInstance := &edgeconnect.AppInstance{
Key: edgeconnect.AppInstanceKey{
Organization: "testorg",
Name: "test-app-1.0.0-instance",
CloudletKey: edgeconnect.CloudletKey{
Organization: "TestCloudletOrg",
Name: "TestCloudlet",
},
},
AppKey: edgeconnect.AppKey{
Organization: "testorg",
Name: "test-app",
Version: "1.0.0",
},
Flavor: edgeconnect.Flavor{
Name: "small",
},
State: "Ready",
PowerState: "PowerOn",
}
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
Return(*existingApp, nil)
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US").
Return(*existingInstance, nil)
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.Equal(t, ActionNone, plan.AppAction.Type)
assert.Len(t, plan.InstanceActions, 1)
assert.Equal(t, ActionNone, plan.InstanceActions[0].Type)
assert.Equal(t, 0, plan.TotalActions)
assert.True(t, plan.IsEmpty())
assert.Contains(t, plan.Summary, "No changes required")
mockClient.AssertExpectations(t)
}
func TestPlanWithOptions(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
opts := PlanOptions{
DryRun: true,
SkipStateCheck: true,
Timeout: 10 * time.Second,
}
ctx := context.Background()
result, err := planner.PlanWithOptions(ctx, testConfig, opts)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.True(t, plan.DryRun)
assert.Equal(t, ActionCreate, plan.AppAction.Type)
assert.Contains(t, plan.AppAction.Reason, "state check skipped")
// No API calls should be made when SkipStateCheck is true
mockClient.AssertNotCalled(t, "ShowApp")
mockClient.AssertNotCalled(t, "ShowAppInstance")
}
func TestPlanMultipleInfrastructures(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Add a second infrastructure target
testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{
Region: "EU",
CloudletOrg: "EUCloudletOrg",
CloudletName: "EUCloudlet",
FlavorName: "medium",
})
// Mock API calls to return "not found" errors
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "EU").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.Equal(t, ActionCreate, plan.AppAction.Type)
// Should have 2 instance actions, one for each infrastructure
require.Len(t, plan.InstanceActions, 2)
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
assert.Equal(t, ActionCreate, plan.InstanceActions[1].Type)
assert.Equal(t, 3, plan.TotalActions) // 1 app + 2 instances
// Test cloudlet and region aggregation
cloudlets := plan.GetTargetCloudlets()
regions := plan.GetTargetRegions()
assert.Len(t, cloudlets, 2)
assert.Len(t, regions, 2)
mockClient.AssertExpectations(t)
}
func TestCalculateManifestHash(t *testing.T) {
planner := &EdgeConnectPlanner{}
tempDir := t.TempDir()
// Create test file
testFile := filepath.Join(tempDir, "test.yaml")
content := "test content for hashing"
err := os.WriteFile(testFile, []byte(content), 0644)
require.NoError(t, err)
hash1, err := planner.calculateManifestHash(testFile)
require.NoError(t, err)
assert.NotEmpty(t, hash1)
assert.Len(t, hash1, 64) // SHA256 hex string length
// Same content should produce same hash
hash2, err := planner.calculateManifestHash(testFile)
require.NoError(t, err)
assert.Equal(t, hash1, hash2)
// Different content should produce different hash
err = os.WriteFile(testFile, []byte("different content"), 0644)
require.NoError(t, err)
hash3, err := planner.calculateManifestHash(testFile)
require.NoError(t, err)
assert.NotEqual(t, hash1, hash3)
// Empty file path should return empty hash
hash4, err := planner.calculateManifestHash("")
require.NoError(t, err)
assert.Empty(t, hash4)
// Non-existent file should return error
_, err = planner.calculateManifestHash("/non/existent/file")
assert.Error(t, err)
}
func TestCompareAppStates(t *testing.T) {
planner := &EdgeConnectPlanner{}
current := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
ManifestHash: "old-hash",
}
desired := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
ManifestHash: "new-hash",
}
changes, manifestChanged := planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.True(t, manifestChanged)
assert.Contains(t, changes[0], "Manifest hash changed")
// Test no changes
desired.ManifestHash = "old-hash"
changes, manifestChanged = planner.compareAppStates(current, desired)
assert.Empty(t, changes)
assert.False(t, manifestChanged)
// Test app type change
desired.AppType = AppTypeDocker
changes, manifestChanged = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.False(t, manifestChanged)
assert.Contains(t, changes[0], "App type changed")
}
func TestCompareAppStatesOutboundConnections(t *testing.T) {
planner := &EdgeConnectPlanner{}
// Test with no outbound connections
current := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
OutboundConnections: nil,
}
desired := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
OutboundConnections: nil,
}
changes, _ := planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when both have no outbound connections")
// Test adding outbound connections
desired.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
// Test identical outbound connections
current.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when outbound connections are identical")
// Test different outbound connections (different port)
desired.OutboundConnections[0].PortRangeMin = 443
desired.OutboundConnections[0].PortRangeMax = 443
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
// Test same connections but different order (should be considered equal)
current.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 443,
PortRangeMax: 443,
RemoteCIDR: "0.0.0.0/0",
},
}
desired.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 443,
PortRangeMax: 443,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order")
// Test removing outbound connections
desired.OutboundConnections = nil
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
}
func TestCompareInstanceStates(t *testing.T) {
planner := &EdgeConnectPlanner{}
current := &InstanceState{
Name: "test-instance",
FlavorName: "small",
CloudletName: "oldcloudlet",
CloudletOrg: "oldorg",
}
desired := &InstanceState{
Name: "test-instance",
FlavorName: "medium",
CloudletName: "newcloudlet",
CloudletOrg: "neworg",
}
changes := planner.compareInstanceStates(current, desired)
assert.Len(t, changes, 3)
assert.Contains(t, changes[0], "Flavor changed")
assert.Contains(t, changes[1], "Cloudlet changed")
assert.Contains(t, changes[2], "Cloudlet org changed")
// Test no changes
desired.FlavorName = "small"
desired.CloudletName = "oldcloudlet"
desired.CloudletOrg = "oldorg"
changes = planner.compareInstanceStates(current, desired)
assert.Empty(t, changes)
}
func TestDeploymentPlanMethods(t *testing.T) {
plan := &DeploymentPlan{
ConfigName: "test-plan",
AppAction: AppAction{
Type: ActionCreate,
Desired: &AppState{Name: "test-app"},
},
InstanceActions: []InstanceAction{
{
Type: ActionCreate,
Target: config.InfraTemplate{
CloudletOrg: "org1",
CloudletName: "cloudlet1",
Region: "US",
},
InstanceName: "instance1",
Desired: &InstanceState{Name: "instance1"},
},
{
Type: ActionUpdate,
Target: config.InfraTemplate{
CloudletOrg: "org2",
CloudletName: "cloudlet2",
Region: "EU",
},
InstanceName: "instance2",
Desired: &InstanceState{Name: "instance2"},
},
},
}
// Test IsEmpty
assert.False(t, plan.IsEmpty())
// Test GetTargetCloudlets
cloudlets := plan.GetTargetCloudlets()
assert.Len(t, cloudlets, 2)
assert.Contains(t, cloudlets, "org1:cloudlet1")
assert.Contains(t, cloudlets, "org2:cloudlet2")
// Test GetTargetRegions
regions := plan.GetTargetRegions()
assert.Len(t, regions, 2)
assert.Contains(t, regions, "US")
assert.Contains(t, regions, "EU")
// Test GenerateSummary
summary := plan.GenerateSummary()
assert.Contains(t, summary, "test-plan")
assert.Contains(t, summary, "CREATE application")
assert.Contains(t, summary, "CREATE 1 instance")
assert.Contains(t, summary, "UPDATE 1 instance")
// Test Validate
err := plan.Validate()
assert.NoError(t, err)
// Test validation failure
plan.AppAction.Desired = nil
err = plan.Validate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "must have desired state")
}
func TestEstimateDeploymentDuration(t *testing.T) {
planner := &EdgeConnectPlanner{}
plan := &DeploymentPlan{
AppAction: AppAction{Type: ActionCreate},
InstanceActions: []InstanceAction{
{Type: ActionCreate},
{Type: ActionUpdate},
},
}
duration := planner.estimateDeploymentDuration(plan)
assert.Greater(t, duration, time.Duration(0))
assert.Less(t, duration, 10*time.Minute) // Reasonable upper bound
// Test with no actions
emptyPlan := &DeploymentPlan{
AppAction: AppAction{Type: ActionNone},
InstanceActions: []InstanceAction{},
}
emptyDuration := planner.estimateDeploymentDuration(emptyPlan)
assert.Greater(t, emptyDuration, time.Duration(0))
assert.Less(t, emptyDuration, duration) // Should be less than plan with actions
}
func TestIsResourceNotFoundError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"nil error", nil, false},
{"not found error", &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true},
{"does not exist error", &edgeconnect.APIError{Messages: []string{"App does not exist"}}, true},
{"404 in message", &edgeconnect.APIError{Messages: []string{"HTTP 404 error"}}, true},
{"other error", &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isResourceNotFoundError(tt.err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestPlanErrorHandling(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Mock API call to return a non-404 error
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}})
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
assert.Error(t, err)
assert.NotNil(t, result)
assert.NotNil(t, result.Error)
assert.Contains(t, err.Error(), "failed to query current app state")
mockClient.AssertExpectations(t)
}

View file

@ -1,462 +0,0 @@
// ABOUTME: Deployment planning types for EdgeConnect apply command with state management
// ABOUTME: Defines structures for deployment plans, actions, and state comparison results
package apply
import (
"fmt"
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
)
// SecurityRule defines network access rules (alias to SDK type for consistency)
type SecurityRule = edgeconnect.SecurityRule
// ActionType represents the type of action to be performed
type ActionType string
const (
// ActionCreate indicates a resource needs to be created
ActionCreate ActionType = "CREATE"
// ActionUpdate indicates a resource needs to be updated
ActionUpdate ActionType = "UPDATE"
// ActionNone indicates no action is needed
ActionNone ActionType = "NONE"
// ActionDelete indicates a resource needs to be deleted (for rollback scenarios)
ActionDelete ActionType = "DELETE"
)
// String returns the string representation of ActionType
func (a ActionType) String() string {
return string(a)
}
// DeploymentPlan represents the complete deployment plan for a configuration
type DeploymentPlan struct {
// ConfigName is the name from metadata
ConfigName string
// AppAction defines what needs to be done with the application
AppAction AppAction
// InstanceActions defines what needs to be done with each instance
InstanceActions []InstanceAction
// Summary provides a human-readable summary of the plan
Summary string
// TotalActions is the count of all actions that will be performed
TotalActions int
// EstimatedDuration is the estimated time to complete the deployment
EstimatedDuration time.Duration
// CreatedAt timestamp when the plan was created
CreatedAt time.Time
// DryRun indicates if this is a dry-run plan
DryRun bool
}
// AppAction represents an action to be performed on an application
type AppAction struct {
// Type of action to perform
Type ActionType
// Current state of the app (nil if doesn't exist)
Current *AppState
// Desired state of the app
Desired *AppState
// Changes describes what will change
Changes []string
// Reason explains why this action is needed
Reason string
// ManifestHash is the hash of the current manifest file
ManifestHash string
// ManifestChanged indicates if the manifest content has changed
ManifestChanged bool
}
// InstanceAction represents an action to be performed on an application instance
type InstanceAction struct {
// Type of action to perform
Type ActionType
// Target infrastructure where the instance will be deployed
Target config.InfraTemplate
// Current state of the instance (nil if doesn't exist)
Current *InstanceState
// Desired state of the instance
Desired *InstanceState
// Changes describes what will change
Changes []string
// Reason explains why this action is needed
Reason string
// InstanceName is the generated name for this instance
InstanceName string
// Dependencies lists other instances this depends on
Dependencies []string
}
// AppState represents the current state of an application
type AppState struct {
// Name of the application
Name string
// Version of the application
Version string
// Organization that owns the app
Organization string
// Region where the app is deployed
Region string
// ManifestHash is the stored hash of the manifest file
ManifestHash string
// LastUpdated timestamp when the app was last modified
LastUpdated time.Time
// Exists indicates if the app currently exists
Exists bool
// AppType indicates whether this is a k8s or docker app
AppType AppType
// OutboundConnections contains the required outbound network connections
OutboundConnections []SecurityRule
}
// InstanceState represents the current state of an application instance
type InstanceState struct {
// Name of the instance
Name string
// AppName that this instance belongs to
AppName string
// AppVersion of the associated app
AppVersion string
// Organization that owns the instance
Organization string
// Region where the instance is deployed
Region string
// CloudletOrg that hosts the cloudlet
CloudletOrg string
// CloudletName where the instance is running
CloudletName string
// FlavorName used for the instance
FlavorName string
// State of the instance (e.g., "Ready", "Pending", "Error")
State string
// PowerState of the instance
PowerState string
// LastUpdated timestamp when the instance was last modified
LastUpdated time.Time
// Exists indicates if the instance currently exists
Exists bool
}
// AppType represents the type of application
type AppType string
const (
// AppTypeK8s represents a Kubernetes application
AppTypeK8s AppType = "k8s"
// AppTypeDocker represents a Docker application
AppTypeDocker AppType = "docker"
)
// String returns the string representation of AppType
func (a AppType) String() string {
return string(a)
}
// DeploymentSummary provides a high-level overview of the deployment plan
type DeploymentSummary struct {
// TotalActions is the total number of actions to be performed
TotalActions int
// ActionCounts breaks down actions by type
ActionCounts map[ActionType]int
// EstimatedDuration for the entire deployment
EstimatedDuration time.Duration
// ResourceSummary describes the resources involved
ResourceSummary ResourceSummary
// Warnings about potential issues
Warnings []string
}
// ResourceSummary provides details about resources in the deployment
type ResourceSummary struct {
// AppsToCreate number of apps that will be created
AppsToCreate int
// AppsToUpdate number of apps that will be updated
AppsToUpdate int
// InstancesToCreate number of instances that will be created
InstancesToCreate int
// InstancesToUpdate number of instances that will be updated
InstancesToUpdate int
// CloudletsAffected number of unique cloudlets involved
CloudletsAffected int
// RegionsAffected number of unique regions involved
RegionsAffected int
}
// PlanResult represents the result of a deployment planning operation
type PlanResult struct {
// Plan is the generated deployment plan
Plan *DeploymentPlan
// Error if planning failed
Error error
// Warnings encountered during planning
Warnings []string
}
// ExecutionResult represents the result of executing a deployment plan
type ExecutionResult struct {
// Plan that was executed
Plan *DeploymentPlan
// Success indicates if the deployment was successful
Success bool
// CompletedActions lists actions that were successfully completed
CompletedActions []ActionResult
// FailedActions lists actions that failed
FailedActions []ActionResult
// Error that caused the deployment to fail (if any)
Error error
// Duration taken to execute the plan
Duration time.Duration
// RollbackPerformed indicates if rollback was executed
RollbackPerformed bool
// RollbackSuccess indicates if rollback was successful
RollbackSuccess bool
}
// ActionResult represents the result of executing a single action
type ActionResult struct {
// Type of action that was attempted
Type ActionType
// Target describes what was being acted upon
Target string
// Success indicates if the action succeeded
Success bool
// Error if the action failed
Error error
// Duration taken to complete the action
Duration time.Duration
// Details provides additional information about the action
Details string
}
// IsEmpty returns true if the deployment plan has no actions to perform
func (dp *DeploymentPlan) IsEmpty() bool {
if dp.AppAction.Type != ActionNone {
return false
}
for _, action := range dp.InstanceActions {
if action.Type != ActionNone {
return false
}
}
return true
}
// HasErrors returns true if the plan contains any error conditions
func (dp *DeploymentPlan) HasErrors() bool {
// Check for conflicting actions or invalid states
return false // Implementation would check for various error conditions
}
// GetTargetCloudlets returns a list of unique cloudlets that will be affected
func (dp *DeploymentPlan) GetTargetCloudlets() []string {
cloudletSet := make(map[string]bool)
var cloudlets []string
for _, action := range dp.InstanceActions {
if action.Type != ActionNone {
key := fmt.Sprintf("%s:%s", action.Target.CloudletOrg, action.Target.CloudletName)
if !cloudletSet[key] {
cloudletSet[key] = true
cloudlets = append(cloudlets, key)
}
}
}
return cloudlets
}
// GetTargetRegions returns a list of unique regions that will be affected
func (dp *DeploymentPlan) GetTargetRegions() []string {
regionSet := make(map[string]bool)
var regions []string
for _, action := range dp.InstanceActions {
if action.Type != ActionNone && !regionSet[action.Target.Region] {
regionSet[action.Target.Region] = true
regions = append(regions, action.Target.Region)
}
}
return regions
}
// GenerateSummary creates a human-readable summary of the deployment plan
func (dp *DeploymentPlan) GenerateSummary() string {
if dp.IsEmpty() {
return "No changes required - configuration matches current state"
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName))
// App actions
if dp.AppAction.Type != ActionNone {
sb.WriteString(fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name))
if len(dp.AppAction.Changes) > 0 {
for _, change := range dp.AppAction.Changes {
sb.WriteString(fmt.Sprintf(" - %s\n", change))
}
}
}
// Instance actions
createCount := 0
updateActions := []InstanceAction{}
for _, action := range dp.InstanceActions {
switch action.Type {
case ActionCreate:
createCount++
case ActionUpdate:
updateActions = append(updateActions, action)
}
}
if createCount > 0 {
sb.WriteString(fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets())))
}
if len(updateActions) > 0 {
sb.WriteString(fmt.Sprintf("- UPDATE %d instance(s)\n", len(updateActions)))
for _, action := range updateActions {
if len(action.Changes) > 0 {
sb.WriteString(fmt.Sprintf(" - Instance '%s':\n", action.InstanceName))
for _, change := range action.Changes {
sb.WriteString(fmt.Sprintf(" - %s\n", change))
}
}
}
}
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
return sb.String()
}
// Validate checks if the deployment plan is valid and safe to execute
func (dp *DeploymentPlan) Validate() error {
if dp.ConfigName == "" {
return fmt.Errorf("deployment plan must have a config name")
}
// Validate app action
if dp.AppAction.Type != ActionNone && dp.AppAction.Desired == nil {
return fmt.Errorf("app action of type %s must have desired state", dp.AppAction.Type)
}
// Validate instance actions
for i, action := range dp.InstanceActions {
if action.Type != ActionNone {
if action.Desired == nil {
return fmt.Errorf("instance action %d of type %s must have desired state", i, action.Type)
}
if action.InstanceName == "" {
return fmt.Errorf("instance action %d must have an instance name", i)
}
}
}
return nil
}
// Clone creates a deep copy of the deployment plan
func (dp *DeploymentPlan) Clone() *DeploymentPlan {
clone := &DeploymentPlan{
ConfigName: dp.ConfigName,
Summary: dp.Summary,
TotalActions: dp.TotalActions,
EstimatedDuration: dp.EstimatedDuration,
CreatedAt: dp.CreatedAt,
DryRun: dp.DryRun,
AppAction: dp.AppAction, // Struct copy is sufficient for this use case
}
// Deep copy instance actions
clone.InstanceActions = make([]InstanceAction, len(dp.InstanceActions))
copy(clone.InstanceActions, dp.InstanceActions)
return clone
}
// convertNetworkRules converts config network rules to EdgeConnect SecurityRules
func convertNetworkRules(network *config.NetworkConfig) []edgeconnect.SecurityRule {
rules := make([]edgeconnect.SecurityRule, len(network.OutboundConnections))
for i, conn := range network.OutboundConnections {
rules[i] = edgeconnect.SecurityRule{
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
}
return rules
}

31
internal/domain/app.go Normal file
View file

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

View file

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

View file

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

309
internal/domain/errors.go Normal file
View file

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

View file

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

View file

@ -0,0 +1,6 @@
package domain
// Flavor defines resource allocation for instances
type Flavor struct {
Name string
}

View file

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

View file

@ -3,7 +3,6 @@
package config package config
import ( import (
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -11,56 +10,47 @@ import (
) )
func TestParseExampleConfig(t *testing.T) { func TestParseExampleConfig(t *testing.T) {
// The base path is relative to the location of this test file
parser := NewParser() parser := NewParser()
cfg, _, err := parser.ParseFile("../../../sdk/examples/comprehensive/EdgeConnectConfig.yaml")
// 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
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, config) require.NotNil(t, cfg)
require.NotEmpty(t, parsedManifest)
// Validate the parsed structure // Basic validation
assert.Equal(t, "edgeconnect-deployment", config.Kind) assert.Equal(t, "edgeconnect-deployment", cfg.Kind)
assert.Equal(t, "edge-app-demo", config.Metadata.Name) assert.Equal(t, "edge-app-demo", cfg.Metadata.Name)
assert.NotNil(t, cfg.Spec.K8sApp)
// Check k8s app configuration assert.NotEmpty(t, cfg.Spec.K8sApp.ManifestFile)
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")
// Check infrastructure template // Check infrastructure template
require.Len(t, config.Spec.InfraTemplate, 1) require.Len(t, cfg.Spec.InfraTemplate, 1)
infra := config.Spec.InfraTemplate[0] infra := cfg.Spec.InfraTemplate[0]
assert.Equal(t, "EU", infra.Region) assert.Equal(t, "EU", infra.Region)
assert.Equal(t, "TelekomOP", infra.CloudletOrg) assert.Equal(t, "TelekomOP", infra.CloudletOrg)
assert.Equal(t, "Munich", infra.CloudletName) assert.Equal(t, "Munich", infra.CloudletName)
assert.Equal(t, "EU.small", infra.FlavorName) assert.Equal(t, "EU.small", infra.FlavorName)
// Check network configuration // Check network configuration
require.NotNil(t, config.Spec.Network) require.NotNil(t, cfg.Spec.Network)
require.Len(t, config.Spec.Network.OutboundConnections, 2) 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, "tcp", conn1.Protocol)
assert.Equal(t, 80, conn1.PortRangeMin) assert.Equal(t, 80, conn1.PortRangeMin)
assert.Equal(t, 80, conn1.PortRangeMax) assert.Equal(t, 80, conn1.PortRangeMax)
assert.Equal(t, "0.0.0.0/0", conn1.RemoteCIDR) 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, "tcp", conn2.Protocol)
assert.Equal(t, 443, conn2.PortRangeMin) assert.Equal(t, 443, conn2.PortRangeMin)
assert.Equal(t, 443, conn2.PortRangeMax) assert.Equal(t, 443, conn2.PortRangeMax)
assert.Equal(t, "0.0.0.0/0", conn2.RemoteCIDR) assert.Equal(t, "0.0.0.0/0", conn2.RemoteCIDR)
// Test utility methods // Test utility methods
assert.Equal(t, "edge-app-demo", config.Metadata.Name) assert.Equal(t, "edge-app-demo", cfg.Metadata.Name)
assert.Contains(t, config.Spec.GetManifestFile(), "k8s-deployment.yaml") assert.Contains(t, cfg.Spec.GetManifestFile(), "k8s-deployment.yaml")
assert.True(t, config.Spec.IsK8sApp()) assert.True(t, cfg.Spec.IsK8sApp())
assert.False(t, config.Spec.IsDockerApp()) assert.False(t, cfg.Spec.IsDockerApp())
} }
func TestValidateExampleStructure(t *testing.T) { func TestValidateExampleStructure(t *testing.T) {

View file

@ -1,7 +1,7 @@
// ABOUTME: Authentication providers for EdgeXR Master Controller API // ABOUTME: Authentication providers for EdgeXR Master Controller API
// ABOUTME: Supports Bearer token authentication with pluggable provider interface // ABOUTME: Supports Bearer token authentication with pluggable provider interface
package edgeconnect package edgeconnect_client
import ( import (
"bytes" "bytes"
@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -138,7 +139,12 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e
if err != nil { if err != nil {
return "", err 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 // Read response body - same as existing implementation
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)

View file

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

View file

@ -1,12 +1,14 @@
// ABOUTME: Core type definitions for EdgeXR Master Controller SDK // ABOUTME: Core type definitions for EdgeXR Master Controller SDK
// ABOUTME: These types are based on the swagger API specification and existing client patterns // ABOUTME: These types are based on the swagger API specification and existing client patterns
package edgeconnect package edgeconnect_client
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"time" "time"
) )
// App field constants for partial updates (based on EdgeXR API specification) // App field constants for partial updates (based on EdgeXR API specification)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +0,0 @@
package main
import "edp.buildth.ing/DevFW-CICD/edge-connect-client/cmd"
func main() {
cmd.Execute()
}

View file

@ -125,14 +125,14 @@ client := client.NewClient(baseURL,
### Simple App Deployment ### Simple App Deployment
```bash ```bash
# Run basic example # 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 ### Comprehensive Workflow
```bash ```bash
# Run full workflow demonstration # Run full workflow demonstration
cd sdk/examples/comprehensive 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 ## Authentication Methods

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,40 +9,43 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"time" "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() { func main() {
// Configure SDK client // 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 // Support both authentication methods
token := getEnvOrDefault("EDGEXR_TOKEN", "") token := getEnvOrDefault("EDGE_CONNECT_TOKEN", "")
username := getEnvOrDefault("EDGEXR_USERNAME", "") username := getEnvOrDefault("EDGE_CONNECT_USERNAME", "")
password := getEnvOrDefault("EDGEXR_PASSWORD", "") password := getEnvOrDefault("EDGE_CONNECT_PASSWORD", "")
var client *edgeconnect.Client var client *edgeconnect_client.Client
if token != "" { if token != "" {
fmt.Println("🔐 Using Bearer token authentication") fmt.Println("🔐 Using Bearer token authentication")
client = edgeconnect.NewClient(baseURL, client = edgeconnect_client.NewClient(baseURL,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)),
edgeconnect.WithLogger(log.Default()), edgeconnect_client.WithLogger(log.Default()),
) )
} else if username != "" && password != "" { } else if username != "" && password != "" {
fmt.Println("🔐 Using username/password authentication") fmt.Println("🔐 Using username/password authentication")
client = edgeconnect.NewClientWithCredentials(baseURL, username, password, client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect.WithLogger(log.Default()), edgeconnect_client.WithLogger(log.Default()),
) )
} else { } 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() ctx := context.Background()
// Configuration for the workflow // Configuration for the workflow
@ -61,7 +64,7 @@ func main() {
fmt.Printf("Organization: %s, Region: %s\n\n", config.Organization, config.Region) fmt.Printf("Organization: %s, Region: %s\n\n", config.Organization, config.Region)
// Run the complete workflow // 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) log.Fatalf("Workflow failed: %v", err)
} }
@ -85,15 +88,19 @@ type WorkflowConfig struct {
FlavorName string 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 ═══") fmt.Println("═══ Phase 1: Application Management ═══")
// 1. Create Application // 1. Create Application
fmt.Println("\n1⃣ Creating application...") fmt.Println("\n1⃣ Creating application...")
app := &edgeconnect.NewAppInput{ app := &NewAppInput{
Region: config.Region, Region: config.Region,
App: edgeconnect.App{ App: App{
Key: edgeconnect.AppKey{ Key: AppKey{
Organization: config.Organization, Organization: config.Organization,
Name: config.AppName, Name: config.AppName,
Version: config.AppVersion, Version: config.AppVersion,
@ -101,10 +108,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
Deployment: "kubernetes", Deployment: "kubernetes",
ImageType: "ImageTypeDocker", // field is ignored ImageType: "ImageTypeDocker", // field is ignored
ImagePath: "https://registry-1.docker.io/library/nginx:latest", // must be set. Even for kubernetes 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 ServerlessConfig: struct{}{}, // must be set
AllowServerless: true, // must be set to true for kubernetes AllowServerless: true, // must be set to true for kubernetes
RequiredOutboundConnections: []edgeconnect.SecurityRule{ RequiredOutboundConnections: []SecurityRule{
{ {
Protocol: "tcp", Protocol: "tcp",
PortRangeMin: 80, 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) return fmt.Errorf("failed to create app: %w", err)
} }
fmt.Printf("✅ App created: %s/%s v%s\n", config.Organization, config.AppName, config.AppVersion) fmt.Printf("✅ App created: %s/%s v%s\n", config.Organization, config.AppName, config.AppVersion)
// 2. Show Application Details // 2. Show Application Details
fmt.Println("\n2⃣ Querying application details...") fmt.Println("\n2⃣ Querying application details...")
appKey := edgeconnect.AppKey{ appKey := AppKey{
Organization: config.Organization, Organization: config.Organization,
Name: config.AppName, Name: config.AppName,
Version: config.AppVersion, 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 { if err != nil {
return fmt.Errorf("failed to show app: %w", err) 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 // 3. List Applications in Organization
fmt.Println("\n3⃣ Listing applications in organization...") fmt.Println("\n3⃣ Listing applications in organization...")
filter := edgeconnect.AppKey{Organization: config.Organization} filter := domain.AppKey{Organization: config.Organization}
apps, err := c.ShowApps(ctx, filter, config.Region) apps, err := adapter.ShowApps(ctx, config.Region, filter)
if err != nil { if err != nil {
return fmt.Errorf("failed to list apps: %w", err) 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 // 4. Create Application Instance
fmt.Println("\n4⃣ Creating application instance...") fmt.Println("\n4⃣ Creating application instance...")
instance := &edgeconnect.NewAppInstanceInput{ instance := &NewAppInstanceInput{
Region: config.Region, Region: config.Region,
AppInst: edgeconnect.AppInstance{ AppInst: AppInstance{
Key: edgeconnect.AppInstanceKey{ Key: AppInstanceKey{
Organization: config.Organization, Organization: config.Organization,
Name: config.InstanceName, Name: config.InstanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: CloudletKey{
Organization: config.CloudletOrg, Organization: config.CloudletOrg,
Name: config.CloudletName, Name: config.CloudletName,
}, },
}, },
AppKey: appKey, 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) return fmt.Errorf("failed to create app instance: %w", err)
} }
fmt.Printf("✅ App instance created: %s on cloudlet %s/%s\n", 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 // 5. Wait for Application Instance to be Ready
fmt.Println("\n5⃣ Waiting for application instance to be ready...") fmt.Println("\n5⃣ Waiting for application instance to be ready...")
instanceKey := edgeconnect.AppInstanceKey{ instanceKey := AppInstanceKey{
Organization: config.Organization, Organization: config.Organization,
Name: config.InstanceName, Name: config.InstanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: CloudletKey{
Organization: config.CloudletOrg, Organization: config.CloudletOrg,
Name: config.CloudletName, 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 { if err != nil {
return fmt.Errorf("failed to wait for instance ready: %w", err) 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 // 6. List Application Instances
fmt.Println("\n6⃣ Listing 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 { if err != nil {
return fmt.Errorf("failed to list app instances: %w", err) 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 // 7. Refresh Application Instance
fmt.Println("\n7⃣ Refreshing 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) return fmt.Errorf("failed to refresh app instance: %w", err)
} }
fmt.Printf("✅ Instance refreshed: %s\n", config.InstanceName) 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 // 8. Show Cloudlet Details
fmt.Println("\n8⃣ Querying cloudlet information...") fmt.Println("\n8⃣ Querying cloudlet information...")
cloudletKey := edgeconnect.CloudletKey{ cloudletKey := CloudletKey{
Organization: config.CloudletOrg, Organization: config.CloudletOrg,
Name: config.CloudletName, 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 { if err != nil {
// This might fail in demo environment, so we'll continue // This might fail in demo environment, so we'll continue
fmt.Printf("⚠️ Could not retrieve cloudlet details: %v\n", err) 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) // 9. Try to Get Cloudlet Manifest (may not be available in demo)
fmt.Println("\n9⃣ Attempting to retrieve cloudlet manifest...") 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 { if err != nil {
fmt.Printf("⚠️ Could not retrieve cloudlet manifest: %v\n", err) fmt.Printf("⚠️ Could not retrieve cloudlet manifest: %v\n", err)
} else { } 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) // 10. Try to Get Cloudlet Resource Usage (may not be available in demo)
fmt.Println("\n🔟 Attempting to retrieve cloudlet resource usage...") 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 { if err != nil {
fmt.Printf("⚠️ Could not retrieve cloudlet usage: %v\n", err) fmt.Printf("⚠️ Could not retrieve cloudlet usage: %v\n", err)
} else { } else {
@ -272,22 +336,40 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
// 11. Delete Application Instance // 11. Delete Application Instance
fmt.Println("\n1⃣1⃣ Cleaning up 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) return fmt.Errorf("failed to delete app instance: %w", err)
} }
fmt.Printf("✅ App instance deleted: %s\n", config.InstanceName) fmt.Printf("✅ App instance deleted: %s\n", config.InstanceName)
// 12. Delete Application // 12. Delete Application
fmt.Println("\n1⃣2⃣ Cleaning up 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) return fmt.Errorf("failed to delete app: %w", err)
} }
fmt.Printf("✅ App deleted: %s/%s v%s\n", config.Organization, config.AppName, config.AppVersion) fmt.Printf("✅ App deleted: %s/%s v%s\n", config.Organization, config.AppName, config.AppVersion)
// 13. Verify Cleanup // 13. Verify Cleanup
fmt.Println("\n1⃣3⃣ Verifying cleanup...") fmt.Println("\n1⃣3⃣ Verifying cleanup...")
_, err = c.ShowApp(ctx, appKey, config.Region) domainAppKey = domain.AppKey{
if err != nil && fmt.Sprintf("%v", err) == edgeconnect.ErrResourceNotFound.Error() { 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") fmt.Printf("✅ Cleanup verified - app no longer exists\n")
} else if err != nil { } else if err != nil {
fmt.Printf("✅ Cleanup appears successful (verification returned: %v)\n", err) 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 // 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) timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
@ -318,36 +400,128 @@ func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKe
for { for {
select { select {
case <-timeoutCtx.Done(): 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: 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 { if err != nil {
// Log error but continue polling // Continue polling on transient errors
fmt.Printf(" ⚠️ Error checking instance state: %v\n", err) fmt.Printf(" (polling) transient error: %v\n", err)
continue continue
} }
fmt.Printf(" 📊 Instance state: %s", instance.State) // Check for a terminal state
if instance.PowerState != "" { if instance.State != "Creating" && instance.State != "Updating" {
fmt.Printf(" (power: %s)", instance.PowerState) if instance.State == "Ready" {
} return fromDomainAppInstance(instance), nil
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
} }
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,
}
}

View file

@ -9,49 +9,52 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"time" "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() { func main() {
// Configure SDK client // 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 // Support both token-based and username/password authentication
token := getEnvOrDefault("EDGEXR_TOKEN", "") token := getEnvOrDefault("EDGE_CONNECT_TOKEN", "")
username := getEnvOrDefault("EDGEXR_USERNAME", "") username := getEnvOrDefault("EDGE_CONNECT_USERNAME", "")
password := getEnvOrDefault("EDGEXR_PASSWORD", "") password := getEnvOrDefault("EDGE_CONNECT_PASSWORD", "")
var edgeClient *edgeconnect.Client var client *edgeconnect_client.Client
if token != "" { if token != "" {
// Use static token authentication // Use static token authentication
fmt.Println("🔐 Using Bearer token authentication") fmt.Println("🔐 Using Bearer token authentication")
edgeClient = edgeconnect.NewClient(baseURL, client = edgeconnect_client.NewClient(baseURL,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)),
edgeconnect.WithLogger(log.Default()), edgeconnect_client.WithLogger(log.Default()),
) )
} else if username != "" && password != "" { } else if username != "" && password != "" {
// Use username/password authentication (matches existing client pattern) // Use username/password authentication (matches existing client pattern)
fmt.Println("🔐 Using username/password authentication") fmt.Println("🔐 Using username/password authentication")
edgeClient = edgeconnect.NewClientWithCredentials(baseURL, username, password, client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect.WithLogger(log.Default()), edgeconnect_client.WithLogger(log.Default()),
) )
} else { } 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() ctx := context.Background()
// Example application to deploy // Example application to deploy
app := &edgeconnect.NewAppInput{ app := &NewAppInput{
Region: "EU", Region: "EU",
App: edgeconnect.App{ App: App{
Key: edgeconnect.AppKey{ Key: AppKey{
Organization: "edp2", Organization: "edp2",
Name: "my-edge-app", Name: "my-edge-app",
Version: "1.0.0", Version: "1.0.0",
@ -59,78 +62,115 @@ func main() {
Deployment: "docker", Deployment: "docker",
ImageType: "ImageTypeDocker", ImageType: "ImageTypeDocker",
ImagePath: "https://registry-1.docker.io/library/nginx:latest", ImagePath: "https://registry-1.docker.io/library/nginx:latest",
DefaultFlavor: edgeconnect.Flavor{Name: "EU.small"}, DefaultFlavor: Flavor{Name: "EU.small"},
ServerlessConfig: struct{}{}, ServerlessConfig: struct{}{},
AllowServerless: false, AllowServerless: false,
}, },
} }
// Demonstrate app lifecycle // 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) log.Fatalf("App lifecycle demonstration failed: %v", err)
} }
fmt.Println("✅ SDK example completed successfully!") 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 appKey := input.App.Key
region := input.Region region := input.Region
var domainAppKey domain.AppKey
fmt.Printf("🚀 Demonstrating EdgeXR SDK with app: %s/%s v%s\n", fmt.Printf("🚀 Demonstrating EdgeXR SDK with app: %s/%s v%s\n",
appKey.Organization, appKey.Name, appKey.Version) appKey.Organization, appKey.Name, appKey.Version)
// Step 1: Create the application // Step 1: Create the application
fmt.Println("\n1. Creating 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) 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 // Defer cleanup to ensure the app is deleted even if subsequent steps fail
fmt.Println("\n2. Querying application...") defer func() {
app, err := edgeClient.ShowApp(ctx, appKey, region) fmt.Println("\n4. Cleaning up: Deleting application...")
if err != nil { domainAppKey = domain.AppKey{
return fmt.Errorf("failed to show app: %w", err) Organization: appKey.Organization,
Name: appKey.Name,
Version: appKey.Version,
} }
fmt.Printf("✅ App found: %s/%s v%s (deployment: %s)\n", if err := adapter.DeleteApp(ctx, region, domainAppKey); err != nil {
app.Key.Organization, app.Key.Name, app.Key.Version, app.Deployment) fmt.Printf(" ⚠️ Cleanup failed: %v\n", err)
// 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 { } else {
return fmt.Errorf("unexpected error verifying deletion: %w", err) fmt.Println(" ✅ App deleted successfully.")
} }
} else { }()
return fmt.Errorf("app still exists after deletion")
// 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 return nil
} }
// Helper to get environment variables or return a default
func getEnvOrDefault(key, defaultValue string) string { func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" { if value, exists := os.LookupEnv(key); exists {
return value return value
} }
return defaultValue 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
}

View file

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