Compare commits
16 commits
main
...
feature/he
| Author | SHA1 | Date | |
|---|---|---|---|
| 488fe430fb | |||
| 00487bec7c | |||
| 054b1c91fc | |||
| 5918ba5db6 | |||
| 5ac67a224d | |||
| 1c13c93512 | |||
| a987e42ad6 | |||
| 8d6f51978d | |||
| 7b062612f5 | |||
| f1ee439c61 | |||
| 19a9807499 | |||
| 8e2e61d61e | |||
| 2625a58691 | |||
| 7b359f81e3 | |||
| e72c81bc43 | |||
| 43d8f277a6 |
67 changed files with 4789 additions and 4301 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -2,6 +2,10 @@ edge-connect
|
|||
# Added by goreleaser init:
|
||||
dist/
|
||||
|
||||
# ignore binaries
|
||||
main
|
||||
bin/
|
||||
|
||||
### direnv ###
|
||||
.direnv
|
||||
.envrc
|
||||
|
|
|
|||
8
Makefile
8
Makefile
|
|
@ -13,17 +13,17 @@ test:
|
|||
|
||||
# Run tests with coverage
|
||||
test-coverage:
|
||||
go test -v -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
GOTOOLCHAIN=go1.25.1 go test -v -coverprofile=coverage.out ./...
|
||||
GOTOOLCHAIN=go1.25.1 go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
# Build the CLI
|
||||
build:
|
||||
go build -o bin/edge-connect .
|
||||
go build -o bin/edge-connect-cli ./cmd/cli
|
||||
|
||||
# Clean generated files and build artifacts
|
||||
clean:
|
||||
rm -f sdk/client/types_generated.go
|
||||
rm -f bin/edge-connect
|
||||
rm -f bin/edge-connect-cli
|
||||
rm -f coverage.out coverage.html
|
||||
|
||||
# Lint the code
|
||||
|
|
|
|||
56
cmd/cli/main.go
Normal file
56
cmd/cli/main.go
Normal 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
|
||||
}
|
||||
72
cmd/root.go
72
cmd/root.go
|
|
@ -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
17
devbox.json
Normal 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
105
devbox.lock
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
outputs = inputs:
|
||||
let
|
||||
goVersion = 25; # Change this to update the whole stack
|
||||
goVersion = "1_25_1"; # Change this to update the whole stack
|
||||
|
||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||
forEachSupportedSystem = f: inputs.nixpkgs.lib.genAttrs supportedSystems (system: f {
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
in
|
||||
{
|
||||
overlays.default = final: prev: {
|
||||
go = final."go_1_${toString goVersion}";
|
||||
go = final."go_${goVersion}";
|
||||
};
|
||||
|
||||
devShells = forEachSupportedSystem ({ pkgs }: {
|
||||
|
|
|
|||
76
hexagonal-architecture-proposal.md
Normal file
76
hexagonal-architecture-proposal.md
Normal 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.
|
||||
705
internal/adapters/driven/edgeconnect/adapter.go
Normal file
705
internal/adapters/driven/edgeconnect/adapter.go
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,46 @@
|
|||
package cmd
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// validateBaseURL checks if the provided string is a valid base URL.
|
||||
// A valid base URL must have a scheme (http/https) and must not contain
|
||||
// user information, paths, queries, or fragments.
|
||||
func validateBaseURL(rawURL string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL format: %w", err)
|
||||
}
|
||||
|
||||
if u.Scheme == "" {
|
||||
return fmt.Errorf("URL must have a scheme (e.g., http, https)")
|
||||
}
|
||||
|
||||
if u.User != nil {
|
||||
return fmt.Errorf("URL should not contain user information")
|
||||
}
|
||||
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return fmt.Errorf("URL should not contain a path")
|
||||
}
|
||||
|
||||
if u.RawQuery != "" {
|
||||
return fmt.Errorf("URL should not contain a query string")
|
||||
}
|
||||
|
||||
if u.Fragment != "" {
|
||||
return fmt.Errorf("URL should not contain a fragment")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
var (
|
||||
organization string
|
||||
appName string
|
||||
|
|
@ -20,58 +48,6 @@ var (
|
|||
region string
|
||||
)
|
||||
|
||||
func validateBaseURL(baseURL string) error {
|
||||
url, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding error '%s'", err.Error())
|
||||
}
|
||||
|
||||
if url.Scheme == "" {
|
||||
return fmt.Errorf("schema should be set (add https://)")
|
||||
}
|
||||
|
||||
if len(url.User.Username()) > 0 {
|
||||
return fmt.Errorf("user and or password should not be set")
|
||||
}
|
||||
|
||||
if !(url.Path == "" || url.Path == "/") {
|
||||
return fmt.Errorf("should not contain any path '%s'", url.Path)
|
||||
}
|
||||
|
||||
if len(url.Query()) > 0 {
|
||||
return fmt.Errorf("should not contain any queries '%s'", url.RawQuery)
|
||||
}
|
||||
|
||||
if len(url.Fragment) > 0 {
|
||||
return fmt.Errorf("should not contain any fragment '%s'", url.Fragment)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newSDKClient() *edgeconnect.Client {
|
||||
baseURL := viper.GetString("base_url")
|
||||
username := viper.GetString("username")
|
||||
password := viper.GetString("password")
|
||||
|
||||
err := validateBaseURL(baseURL)
|
||||
if err != nil {
|
||||
fmt.Printf("Error parsing baseURL: '%s' with error: %s\n", baseURL, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if username != "" && password != "" {
|
||||
return edgeconnect.NewClientWithCredentials(baseURL, username, password,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback to no auth for now - in production should require auth
|
||||
return edgeconnect.NewClient(baseURL,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
)
|
||||
}
|
||||
|
||||
var appCmd = &cobra.Command{
|
||||
Use: "app",
|
||||
Short: "Manage Edge Connect applications",
|
||||
|
|
@ -82,19 +58,15 @@ var createAppCmd = &cobra.Command{
|
|||
Use: "create",
|
||||
Short: "Create a new Edge Connect application",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
input := &edgeconnect.NewAppInput{
|
||||
Region: region,
|
||||
App: edgeconnect.App{
|
||||
Key: edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
},
|
||||
app := &domain.App{
|
||||
Key: domain.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
},
|
||||
}
|
||||
|
||||
err := c.CreateApp(context.Background(), input)
|
||||
err := services.AppService.CreateApp(context.Background(), region, app)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating app: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -107,15 +79,24 @@ var showAppCmd = &cobra.Command{
|
|||
Use: "show",
|
||||
Short: "Show details of an Edge Connect application",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
appKey := edgeconnect.AppKey{
|
||||
appKey := domain.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
|
||||
app, err := c.ShowApp(context.Background(), appKey, region)
|
||||
app, err := services.AppService.ShowApp(context.Background(), region, appKey)
|
||||
if err != nil {
|
||||
// Handle domain-specific errors with appropriate user feedback
|
||||
if domain.IsNotFoundError(err) {
|
||||
fmt.Printf("Application %s/%s (version %s) not found in region %s\n",
|
||||
appKey.Organization, appKey.Name, appKey.Version, region)
|
||||
os.Exit(1)
|
||||
}
|
||||
if domain.IsValidationError(err) {
|
||||
fmt.Printf("Validation error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Error showing app: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -127,14 +108,13 @@ var listAppsCmd = &cobra.Command{
|
|||
Use: "list",
|
||||
Short: "List Edge Connect applications",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
appKey := edgeconnect.AppKey{
|
||||
appKey := domain.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
|
||||
apps, err := c.ShowApps(context.Background(), appKey, region)
|
||||
apps, err := services.AppService.ShowApps(context.Background(), region, appKey)
|
||||
if err != nil {
|
||||
fmt.Printf("Error listing apps: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -150,14 +130,13 @@ var deleteAppCmd = &cobra.Command{
|
|||
Use: "delete",
|
||||
Short: "Delete an Edge Connect application",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
appKey := edgeconnect.AppKey{
|
||||
appKey := domain.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
|
||||
err := c.DeleteApp(context.Background(), appKey, region)
|
||||
err := services.AppService.DeleteApp(context.Background(), region, appKey)
|
||||
if err != nil {
|
||||
fmt.Printf("Error deleting app: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -177,12 +156,18 @@ func init() {
|
|||
cmd.Flags().StringVarP(&appName, "name", "n", "", "application name")
|
||||
cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
|
||||
cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)")
|
||||
cmd.MarkFlagRequired("org")
|
||||
cmd.MarkFlagRequired("region")
|
||||
if err := cmd.MarkFlagRequired("org"); err != nil {
|
||||
panic(fmt.Sprintf("Failed to mark 'org' flag as required: %v", err))
|
||||
}
|
||||
if err := cmd.MarkFlagRequired("region"); err != nil {
|
||||
panic(fmt.Sprintf("Failed to mark 'region' flag as required: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Add required name flag for specific commands
|
||||
for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} {
|
||||
cmd.MarkFlagRequired("name")
|
||||
if err := cmd.MarkFlagRequired("name"); err != nil {
|
||||
panic(fmt.Sprintf("Failed to mark 'name' flag as required: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package cmd
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
|
@ -1,17 +1,21 @@
|
|||
// ABOUTME: CLI command for declarative deployment of EdgeConnect applications from YAML configuration
|
||||
// ABOUTME: Integrates config parser, deployment planner, and resource manager for complete deployment workflow
|
||||
package cmd
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/apply"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -30,7 +34,9 @@ the necessary changes to deploy your applications across multiple cloudlets.`,
|
|||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if configFile == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n")
|
||||
cmd.Usage()
|
||||
if err := cmd.Usage(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to display usage: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -68,10 +74,35 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error {
|
|||
fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name)
|
||||
|
||||
// Step 3: Create EdgeConnect client
|
||||
client := newSDKClient()
|
||||
baseURL := getEnvOrDefault("EDGE_CONNECT_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live")
|
||||
token := getEnvOrDefault("EDGE_CONNECT_TOKEN", "")
|
||||
username := getEnvOrDefault("EDGE_CONNECT_USERNAME", "")
|
||||
password := getEnvOrDefault("EDGE_CONNECT_PASSWORD", "")
|
||||
|
||||
// Step 4: Create deployment planner
|
||||
planner := apply.NewPlanner(client)
|
||||
var client *edgeconnect_client.Client
|
||||
|
||||
if token != "" {
|
||||
fmt.Println("🔐 Using Bearer token authentication")
|
||||
client = edgeconnect_client.NewClient(baseURL,
|
||||
edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)),
|
||||
edgeconnect_client.WithLogger(log.Default()),
|
||||
)
|
||||
} else if username != "" && password != "" {
|
||||
fmt.Println("🔐 Using username/password authentication")
|
||||
client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password,
|
||||
edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect_client.WithLogger(log.Default()),
|
||||
)
|
||||
} else {
|
||||
log.Fatal("Authentication required: Set either EDGE_CONNECT_TOKEN or both EDGE_CONNECT_USERNAME and EDGE_CONNECT_PASSWORD")
|
||||
}
|
||||
|
||||
// Step 4: Create driven adapter
|
||||
adapter := edgeconnect.NewAdapter(client)
|
||||
|
||||
// Step 5: Create deployment planner
|
||||
planner := apply.NewPlanner(adapter, adapter)
|
||||
|
||||
// Step 5: Generate deployment plan
|
||||
fmt.Println("🔍 Analyzing current state and generating deployment plan...")
|
||||
|
|
@ -121,7 +152,7 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error {
|
|||
// Step 9: Execute deployment
|
||||
fmt.Println("\n🚀 Starting deployment...")
|
||||
|
||||
manager := apply.NewResourceManager(client, apply.WithLogger(log.Default()))
|
||||
manager := apply.NewResourceManager(adapter, adapter, apply.WithLogger(log.Default()))
|
||||
deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deployment failed: %w", err)
|
||||
|
|
@ -156,7 +187,10 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error {
|
|||
func confirmDeployment() bool {
|
||||
fmt.Print("Do you want to proceed? (yes/no): ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if _, err := fmt.Scanln(&response); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to read input: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
switch response {
|
||||
case "yes", "y", "YES", "Y":
|
||||
|
|
@ -166,6 +200,13 @@ func confirmDeployment() bool {
|
|||
}
|
||||
}
|
||||
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(applyCmd)
|
||||
|
||||
|
|
@ -173,5 +214,7 @@ func init() {
|
|||
applyCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without applying them")
|
||||
applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan")
|
||||
|
||||
applyCmd.MarkFlagRequired("file")
|
||||
if err := applyCmd.MarkFlagRequired("file"); err != nil {
|
||||
panic(fmt.Sprintf("Failed to mark 'file' flag as required: %v", err))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
package cmd
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -26,30 +26,26 @@ var createInstanceCmd = &cobra.Command{
|
|||
Use: "create",
|
||||
Short: "Create a new Edge Connect application instance",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
input := &edgeconnect.NewAppInstanceInput{
|
||||
Region: region,
|
||||
AppInst: edgeconnect.AppInstance{
|
||||
Key: edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
},
|
||||
AppKey: edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
},
|
||||
Flavor: edgeconnect.Flavor{
|
||||
Name: flavorName,
|
||||
appInst := &domain.AppInstance{
|
||||
Key: domain.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: domain.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
},
|
||||
AppKey: domain.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
},
|
||||
Flavor: domain.Flavor{
|
||||
Name: flavorName,
|
||||
},
|
||||
}
|
||||
|
||||
err := c.CreateAppInstance(context.Background(), input)
|
||||
err := services.InstanceService.CreateAppInstance(context.Background(), region, appInst)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating app instance: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -62,17 +58,16 @@ var showInstanceCmd = &cobra.Command{
|
|||
Use: "show",
|
||||
Short: "Show details of an Edge Connect application instance",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
instanceKey := domain.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
CloudletKey: domain.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
instance, err := c.ShowAppInstance(context.Background(), instanceKey, region)
|
||||
instance, err := services.InstanceService.ShowAppInstance(context.Background(), region, instanceKey)
|
||||
if err != nil {
|
||||
fmt.Printf("Error showing app instance: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -85,17 +80,16 @@ var listInstancesCmd = &cobra.Command{
|
|||
Use: "list",
|
||||
Short: "List Edge Connect application instances",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
instanceKey := domain.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
CloudletKey: domain.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
instances, err := c.ShowAppInstances(context.Background(), instanceKey, region)
|
||||
instances, err := services.InstanceService.ShowAppInstances(context.Background(), region, instanceKey)
|
||||
if err != nil {
|
||||
fmt.Printf("Error listing app instances: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -111,17 +105,16 @@ var deleteInstanceCmd = &cobra.Command{
|
|||
Use: "delete",
|
||||
Short: "Delete an Edge Connect application instance",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
instanceKey := domain.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
CloudletKey: domain.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
err := c.DeleteAppInstance(context.Background(), instanceKey, region)
|
||||
err := services.InstanceService.DeleteAppInstance(context.Background(), region, instanceKey)
|
||||
if err != nil {
|
||||
fmt.Printf("Error deleting app instance: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -143,17 +136,21 @@ func init() {
|
|||
cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)")
|
||||
cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)")
|
||||
|
||||
cmd.MarkFlagRequired("org")
|
||||
cmd.MarkFlagRequired("name")
|
||||
cmd.MarkFlagRequired("cloudlet")
|
||||
cmd.MarkFlagRequired("cloudlet-org")
|
||||
cmd.MarkFlagRequired("region")
|
||||
for _, flag := range []string{"org", "name", "cloudlet", "cloudlet-org", "region"} {
|
||||
if err := cmd.MarkFlagRequired(flag); err != nil {
|
||||
panic(fmt.Sprintf("Failed to mark '%s' flag as required: %v", flag, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional flags for create command
|
||||
createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)")
|
||||
createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
|
||||
createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)")
|
||||
createInstanceCmd.MarkFlagRequired("app")
|
||||
createInstanceCmd.MarkFlagRequired("flavor")
|
||||
}
|
||||
if err := createInstanceCmd.MarkFlagRequired("app"); err != nil {
|
||||
panic(fmt.Sprintf("Failed to mark 'app' flag as required: %v", err))
|
||||
}
|
||||
if err := createInstanceCmd.MarkFlagRequired("flavor"); err != nil {
|
||||
panic(fmt.Sprintf("Failed to mark 'flavor' flag as required: %v", err))
|
||||
}
|
||||
}
|
||||
112
internal/adapters/driving/cli/organization.go
Normal file
112
internal/adapters/driving/cli/organization.go
Normal 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
|
||||
},
|
||||
}
|
||||
111
internal/adapters/driving/cli/root.go
Normal file
111
internal/adapters/driving/cli/root.go
Normal 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())
|
||||
}
|
||||
}
|
||||
157
internal/application/app/service.go
Normal file
157
internal/application/app/service.go
Normal 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
|
||||
}
|
||||
|
|
@ -7,8 +7,9 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||
)
|
||||
|
||||
// ResourceManagerInterface defines the interface for resource management
|
||||
|
|
@ -25,7 +26,8 @@ type ResourceManagerInterface interface {
|
|||
|
||||
// EdgeConnectResourceManager implements resource management for EdgeConnect
|
||||
type EdgeConnectResourceManager struct {
|
||||
client EdgeConnectClientInterface
|
||||
appRepo driven.AppRepository
|
||||
appInstRepo driven.AppInstanceRepository
|
||||
parallelLimit int
|
||||
rollbackOnFail bool
|
||||
logger Logger
|
||||
|
|
@ -66,14 +68,15 @@ func DefaultResourceManagerOptions() ResourceManagerOptions {
|
|||
}
|
||||
|
||||
// NewResourceManager creates a new EdgeConnect resource manager
|
||||
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
||||
func NewResourceManager(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
||||
options := DefaultResourceManagerOptions()
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
return &EdgeConnectResourceManager{
|
||||
client: client,
|
||||
appRepo: appRepo,
|
||||
appInstRepo: appInstRepo,
|
||||
parallelLimit: options.ParallelLimit,
|
||||
rollbackOnFail: options.RollbackOnFail,
|
||||
logger: options.Logger,
|
||||
|
|
@ -133,7 +136,7 @@ func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan
|
|||
strategyConfig := rm.strategyConfig
|
||||
strategyConfig.ParallelOperations = rm.parallelLimit > 1
|
||||
|
||||
factory := NewStrategyFactory(rm.client, strategyConfig, rm.logger)
|
||||
factory := NewStrategyFactory(rm.appRepo, rm.appInstRepo, strategyConfig, rm.logger)
|
||||
strategy, err := factory.CreateStrategy(strategyName)
|
||||
if err != nil {
|
||||
result := &ExecutionResult{
|
||||
|
|
@ -190,8 +193,8 @@ func (rm *EdgeConnectResourceManager) ValidatePrerequisites(ctx context.Context,
|
|||
}
|
||||
|
||||
// Validate that we have required client capabilities
|
||||
if rm.client == nil {
|
||||
return fmt.Errorf("EdgeConnect client is not configured")
|
||||
if rm.appRepo == nil || rm.appInstRepo == nil {
|
||||
return fmt.Errorf("repositories are not configured")
|
||||
}
|
||||
|
||||
rm.logf("Prerequisites validation passed")
|
||||
|
|
@ -250,13 +253,13 @@ func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context,
|
|||
|
||||
// rollbackApp deletes an application that was created
|
||||
func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
|
||||
appKey := edgeconnect.AppKey{
|
||||
appKey := domain.AppKey{
|
||||
Organization: plan.AppAction.Desired.Organization,
|
||||
Name: plan.AppAction.Desired.Name,
|
||||
Version: plan.AppAction.Desired.Version,
|
||||
}
|
||||
|
||||
return rm.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region)
|
||||
return rm.appRepo.DeleteApp(ctx, plan.AppAction.Desired.Region, appKey)
|
||||
}
|
||||
|
||||
// rollbackInstance deletes an instance that was created
|
||||
|
|
@ -264,15 +267,15 @@ func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, acti
|
|||
// Find the instance action to get the details
|
||||
for _, instanceAction := range plan.InstanceActions {
|
||||
if instanceAction.InstanceName == action.Target {
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
instanceKey := domain.AppInstanceKey{
|
||||
Organization: plan.AppAction.Desired.Organization,
|
||||
Name: instanceAction.InstanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
CloudletKey: domain.CloudletKey{
|
||||
Organization: instanceAction.Target.CloudletOrg,
|
||||
Name: instanceAction.Target.CloudletName,
|
||||
},
|
||||
}
|
||||
return rm.client.DeleteAppInstance(ctx, instanceKey, instanceAction.Target.Region)
|
||||
return rm.appInstRepo.DeleteAppInstance(ctx, instanceAction.Target.Region, instanceKey)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("instance action not found for rollback: %s", action.Target)
|
||||
|
|
@ -283,4 +286,4 @@ func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
|
|||
if rm.logger != nil {
|
||||
rm.logger.Printf("[ResourceManager] "+format, v...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -19,36 +19,61 @@ import (
|
|||
|
||||
// MockResourceClient extends MockEdgeConnectClient with resource management methods
|
||||
type MockResourceClient struct {
|
||||
MockEdgeConnectClient
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
func (m *MockResourceClient) CreateApp(ctx context.Context, region string, app *domain.App) error {
|
||||
args := m.Called(ctx, region, app)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
func (m *MockResourceClient) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) {
|
||||
args := m.Called(ctx, region, appKey)
|
||||
return args.Get(0).(*domain.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) {
|
||||
args := m.Called(ctx, region, appKey)
|
||||
return args.Get(0).([]domain.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error {
|
||||
args := m.Called(ctx, region, appKey)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
func (m *MockResourceClient) UpdateApp(ctx context.Context, region string, app *domain.App) error {
|
||||
args := m.Called(ctx, region, app)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
func (m *MockResourceClient) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
|
||||
args := m.Called(ctx, region, appInst)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
func (m *MockResourceClient) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) {
|
||||
args := m.Called(ctx, region, appInstKey)
|
||||
return args.Get(0).(*domain.AppInstance), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) {
|
||||
args := m.Called(ctx, region, appInstKey)
|
||||
return args.Get(0).([]domain.AppInstance), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
|
||||
args := m.Called(ctx, region, appInstKey)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
|
||||
args := m.Called(ctx, region, appInst)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
|
||||
args := m.Called(ctx, region, appInstKey)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
|
|
@ -62,8 +87,9 @@ func (l *TestLogger) Printf(format string, v ...interface{}) {
|
|||
}
|
||||
|
||||
func TestNewResourceManager(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
mockAppRepo := &MockResourceClient{}
|
||||
mockAppInstRepo := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
|
||||
|
||||
assert.NotNil(t, manager)
|
||||
assert.IsType(t, &EdgeConnectResourceManager{}, manager)
|
||||
|
|
@ -78,10 +104,11 @@ func TestDefaultResourceManagerOptions(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestWithOptions(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
mockAppRepo := &MockResourceClient{}
|
||||
mockAppInstRepo := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
|
||||
manager := NewResourceManager(mockClient,
|
||||
manager := NewResourceManager(mockAppRepo, mockAppInstRepo,
|
||||
WithParallelLimit(10),
|
||||
WithRollbackOnFail(false),
|
||||
WithLogger(logger),
|
||||
|
|
@ -177,17 +204,18 @@ func createTestStrategyConfig() StrategyConfig {
|
|||
}
|
||||
|
||||
func TestApplyDeploymentSuccess(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
mockAppRepo := &MockResourceClient{}
|
||||
mockAppInstRepo := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
// Mock successful operations
|
||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")).
|
||||
mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
|
||||
Return(nil)
|
||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")).
|
||||
mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")).
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
|
@ -204,20 +232,22 @@ func TestApplyDeploymentSuccess(t *testing.T) {
|
|||
// Check that operations were logged
|
||||
assert.Greater(t, len(logger.messages), 0)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
mockAppRepo.AssertExpectations(t)
|
||||
mockAppInstRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestApplyDeploymentAppFailure(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
mockAppRepo := &MockResourceClient{}
|
||||
mockAppInstRepo := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
// Mock app creation failure - deployment should stop here
|
||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")).
|
||||
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}})
|
||||
mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
|
||||
Return(fmt.Errorf("Server error"))
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
||||
|
|
@ -229,25 +259,27 @@ func TestApplyDeploymentAppFailure(t *testing.T) {
|
|||
assert.Len(t, result.FailedActions, 1)
|
||||
assert.Contains(t, err.Error(), "Server error")
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
mockAppRepo.AssertExpectations(t)
|
||||
mockAppInstRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
mockAppRepo := &MockResourceClient{}
|
||||
mockAppInstRepo := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig()))
|
||||
manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
// Mock successful app creation but failed instance creation
|
||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")).
|
||||
mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
|
||||
Return(nil)
|
||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")).
|
||||
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}})
|
||||
mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")).
|
||||
Return(fmt.Errorf("Instance creation failed"))
|
||||
|
||||
// Mock rollback operations
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
|
||||
mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")).
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
|
@ -262,12 +294,14 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
|
|||
assert.True(t, result.RollbackSuccess)
|
||||
assert.Contains(t, err.Error(), "failed to create instance")
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
mockAppRepo.AssertExpectations(t)
|
||||
mockAppInstRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestApplyDeploymentNoActions(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
mockAppRepo := &MockResourceClient{}
|
||||
mockAppInstRepo := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
|
||||
|
||||
// Create empty plan
|
||||
plan := &DeploymentPlan{
|
||||
|
|
@ -283,14 +317,15 @@ func TestApplyDeploymentNoActions(t *testing.T) {
|
|||
require.NotNil(t, result)
|
||||
assert.Contains(t, err.Error(), "deployment plan is empty")
|
||||
|
||||
mockClient.AssertNotCalled(t, "CreateApp")
|
||||
mockClient.AssertNotCalled(t, "CreateAppInstance")
|
||||
mockAppRepo.AssertNotCalled(t, "CreateApp")
|
||||
mockAppInstRepo.AssertNotCalled(t, "CreateAppInstance")
|
||||
}
|
||||
|
||||
func TestApplyDeploymentMultipleInstances(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
mockAppRepo := &MockResourceClient{}
|
||||
mockAppInstRepo := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig()))
|
||||
manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
// Create plan with multiple instances
|
||||
plan := &DeploymentPlan{
|
||||
|
|
@ -333,9 +368,9 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
|
|||
config := createTestManagerConfig(t)
|
||||
|
||||
// Mock successful operations
|
||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")).
|
||||
mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
|
||||
Return(nil)
|
||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")).
|
||||
mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")).
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
|
@ -347,12 +382,14 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
|
|||
assert.Len(t, result.CompletedActions, 3) // 1 app + 2 instances
|
||||
assert.Len(t, result.FailedActions, 0)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
mockAppRepo.AssertExpectations(t)
|
||||
mockAppInstRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestValidatePrerequisites(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
mockAppRepo := &MockResourceClient{}
|
||||
mockAppInstRepo := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -397,9 +434,10 @@ func TestValidatePrerequisites(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestRollbackDeployment(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
mockAppRepo := &MockResourceClient{}
|
||||
mockAppInstRepo := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
// Create result with completed actions
|
||||
plan := createTestDeploymentPlan()
|
||||
|
|
@ -421,24 +459,26 @@ func TestRollbackDeployment(t *testing.T) {
|
|||
}
|
||||
|
||||
// Mock rollback operations
|
||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US").
|
||||
mockAppInstRepo.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppInstanceKey")).
|
||||
Return(nil)
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
|
||||
mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")).
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
err := manager.RollbackDeployment(ctx, result)
|
||||
|
||||
require.NoError(t, err)
|
||||
mockClient.AssertExpectations(t)
|
||||
mockAppRepo.AssertExpectations(t)
|
||||
mockAppInstRepo.AssertExpectations(t)
|
||||
|
||||
// Check rollback was logged
|
||||
assert.Greater(t, len(logger.messages), 0)
|
||||
}
|
||||
|
||||
func TestRollbackDeploymentFailure(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
mockAppRepo := &MockResourceClient{}
|
||||
mockAppInstRepo := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
result := &ExecutionResult{
|
||||
|
|
@ -453,15 +493,16 @@ func TestRollbackDeploymentFailure(t *testing.T) {
|
|||
}
|
||||
|
||||
// Mock rollback failure
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
|
||||
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Delete failed"}})
|
||||
mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")).
|
||||
Return(fmt.Errorf("Delete failed"))
|
||||
|
||||
ctx := context.Background()
|
||||
err := manager.RollbackDeployment(ctx, result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "rollback encountered")
|
||||
mockClient.AssertExpectations(t)
|
||||
mockAppRepo.AssertExpectations(t)
|
||||
mockAppInstRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestConvertNetworkRules(t *testing.T) {
|
||||
138
internal/application/apply/mocks_test.go
Normal file
138
internal/application/apply/mocks_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -11,22 +11,11 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||
)
|
||||
|
||||
// EdgeConnectClientInterface defines the methods needed for deployment planning
|
||||
type EdgeConnectClientInterface interface {
|
||||
ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error)
|
||||
CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error
|
||||
UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error
|
||||
DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error
|
||||
ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error)
|
||||
CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error
|
||||
UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error
|
||||
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error
|
||||
}
|
||||
|
||||
// Planner defines the interface for deployment planning
|
||||
type Planner interface {
|
||||
// Plan analyzes the configuration and current state to generate a deployment plan
|
||||
|
|
@ -67,13 +56,15 @@ func DefaultPlanOptions() PlanOptions {
|
|||
|
||||
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
|
||||
type EdgeConnectPlanner struct {
|
||||
client EdgeConnectClientInterface
|
||||
appRepo driven.AppRepository
|
||||
appInstRepo driven.AppInstanceRepository
|
||||
}
|
||||
|
||||
// NewPlanner creates a new EdgeConnect deployment planner
|
||||
func NewPlanner(client EdgeConnectClientInterface) Planner {
|
||||
func NewPlanner(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) Planner {
|
||||
return &EdgeConnectPlanner{
|
||||
client: client,
|
||||
appRepo: appRepo,
|
||||
appInstRepo: appInstRepo,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -148,9 +139,9 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E
|
|||
|
||||
// Extract outbound connections from config
|
||||
if config.Spec.Network != nil {
|
||||
desired.OutboundConnections = make([]SecurityRule, len(config.Spec.Network.OutboundConnections))
|
||||
desired.OutboundConnections = make([]domain.SecurityRule, len(config.Spec.Network.OutboundConnections))
|
||||
for i, conn := range config.Spec.Network.OutboundConnections {
|
||||
desired.OutboundConnections[i] = SecurityRule{
|
||||
desired.OutboundConnections[i] = domain.SecurityRule{
|
||||
Protocol: conn.Protocol,
|
||||
PortRangeMin: conn.PortRangeMin,
|
||||
PortRangeMax: conn.PortRangeMax,
|
||||
|
|
@ -285,13 +276,13 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
|
|||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
appKey := edgeconnect.AppKey{
|
||||
appKey := domain.AppKey{
|
||||
Organization: desired.Organization,
|
||||
Name: desired.Name,
|
||||
Version: desired.Version,
|
||||
}
|
||||
|
||||
app, err := p.client.ShowApp(timeoutCtx, appKey, desired.Region)
|
||||
app, err := p.appRepo.ShowApp(timeoutCtx, desired.Region, appKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -321,9 +312,9 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
|
|||
}
|
||||
|
||||
// Extract outbound connections from the app
|
||||
current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections))
|
||||
current.OutboundConnections = make([]domain.SecurityRule, len(app.RequiredOutboundConnections))
|
||||
for i, conn := range app.RequiredOutboundConnections {
|
||||
current.OutboundConnections[i] = SecurityRule{
|
||||
current.OutboundConnections[i] = domain.SecurityRule{
|
||||
Protocol: conn.Protocol,
|
||||
PortRangeMin: conn.PortRangeMin,
|
||||
PortRangeMax: conn.PortRangeMax,
|
||||
|
|
@ -339,16 +330,16 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire
|
|||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
instanceKey := domain.AppInstanceKey{
|
||||
Organization: desired.Organization,
|
||||
Name: desired.Name,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
CloudletKey: domain.CloudletKey{
|
||||
Organization: desired.CloudletOrg,
|
||||
Name: desired.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region)
|
||||
instance, err := p.appInstRepo.ShowAppInstance(timeoutCtx, desired.Region, instanceKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -405,10 +396,10 @@ func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]str
|
|||
}
|
||||
|
||||
// compareOutboundConnections compares two sets of outbound connections for equality
|
||||
func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) []string {
|
||||
func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []domain.SecurityRule) []string {
|
||||
var changes []string
|
||||
makeMap := func(rules []SecurityRule) map[string]SecurityRule {
|
||||
m := make(map[string]SecurityRule, len(rules))
|
||||
makeMap := func(rules []domain.SecurityRule) map[string]domain.SecurityRule {
|
||||
m := make(map[string]domain.SecurityRule, len(rules))
|
||||
for _, r := range rules {
|
||||
key := fmt.Sprintf("%s:%d-%d:%s",
|
||||
strings.ToLower(r.Protocol),
|
||||
|
|
@ -470,7 +461,12 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string,
|
|||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open manifest file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
// Log error but don't fail the operation as hash is already computed
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to close manifest file: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
|
|
@ -505,18 +501,20 @@ func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) ti
|
|||
var duration time.Duration
|
||||
|
||||
// App operations
|
||||
if plan.AppAction.Type == ActionCreate {
|
||||
switch plan.AppAction.Type {
|
||||
case ActionCreate:
|
||||
duration += 30 * time.Second
|
||||
} else if plan.AppAction.Type == ActionUpdate {
|
||||
case ActionUpdate:
|
||||
duration += 15 * time.Second
|
||||
}
|
||||
|
||||
// Instance operations (can be done in parallel)
|
||||
instanceDuration := time.Duration(0)
|
||||
for _, action := range plan.InstanceActions {
|
||||
if action.Type == ActionCreate {
|
||||
switch action.Type {
|
||||
case ActionCreate:
|
||||
instanceDuration = max(instanceDuration, 2*time.Minute)
|
||||
} else if action.Type == ActionUpdate {
|
||||
case ActionUpdate:
|
||||
instanceDuration = max(instanceDuration, 1*time.Minute)
|
||||
}
|
||||
}
|
||||
|
|
@ -552,4 +550,4 @@ func max(a, b time.Duration) time.Duration {
|
|||
// getInstanceName generates the instance name following the pattern: appName-appVersion-instance
|
||||
func getInstanceName(appName, appVersion string) string {
|
||||
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
|
||||
}
|
||||
}
|
||||
760
internal/application/apply/planner_test.go
Normal file
760
internal/application/apply/planner_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -7,7 +7,8 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||
)
|
||||
|
||||
// DeploymentStrategy represents the type of deployment strategy
|
||||
|
|
@ -66,17 +67,19 @@ func DefaultStrategyConfig() StrategyConfig {
|
|||
|
||||
// StrategyFactory creates deployment strategy executors
|
||||
type StrategyFactory struct {
|
||||
config StrategyConfig
|
||||
client EdgeConnectClientInterface
|
||||
logger Logger
|
||||
config StrategyConfig
|
||||
appRepo driven.AppRepository
|
||||
appInstRepo driven.AppInstanceRepository
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewStrategyFactory creates a new strategy factory
|
||||
func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *StrategyFactory {
|
||||
func NewStrategyFactory(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, config StrategyConfig, logger Logger) *StrategyFactory {
|
||||
return &StrategyFactory{
|
||||
config: config,
|
||||
client: client,
|
||||
logger: logger,
|
||||
config: config,
|
||||
appRepo: appRepo,
|
||||
appInstRepo: appInstRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +87,7 @@ func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig
|
|||
func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) {
|
||||
switch strategy {
|
||||
case StrategyRecreate:
|
||||
return NewRecreateStrategy(f.client, f.config, f.logger), nil
|
||||
return NewRecreateStrategy(f.appRepo, f.appInstRepo, f.config, f.logger), nil
|
||||
case StrategyBlueGreen:
|
||||
// TODO: Implement blue-green strategy
|
||||
return nil, fmt.Errorf("blue-green strategy not yet implemented")
|
||||
|
|
@ -103,4 +106,4 @@ func (f *StrategyFactory) GetAvailableStrategies() []DeploymentStrategy {
|
|||
// StrategyBlueGreen, // TODO: Enable when implemented
|
||||
// StrategyRolling, // TODO: Enable when implemented
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,23 +9,26 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||
)
|
||||
|
||||
// RecreateStrategy implements the recreate deployment strategy
|
||||
type RecreateStrategy struct {
|
||||
client EdgeConnectClientInterface
|
||||
config StrategyConfig
|
||||
logger Logger
|
||||
appRepo driven.AppRepository
|
||||
appInstRepo driven.AppInstanceRepository
|
||||
config StrategyConfig
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewRecreateStrategy creates a new recreate strategy executor
|
||||
func NewRecreateStrategy(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *RecreateStrategy {
|
||||
func NewRecreateStrategy(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, config StrategyConfig, logger Logger) *RecreateStrategy {
|
||||
return &RecreateStrategy{
|
||||
client: client,
|
||||
config: config,
|
||||
logger: logger,
|
||||
appRepo: appRepo,
|
||||
appInstRepo: appInstRepo,
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,13 +186,13 @@ func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentP
|
|||
|
||||
r.logf("Phase 2: Deleting existing application")
|
||||
|
||||
appKey := edgeconnect.AppKey{
|
||||
appKey := domain.AppKey{
|
||||
Organization: plan.AppAction.Desired.Organization,
|
||||
Name: plan.AppAction.Desired.Name,
|
||||
Version: plan.AppAction.Desired.Version,
|
||||
}
|
||||
|
||||
if err := r.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region); err != nil {
|
||||
if err := r.appRepo.DeleteApp(ctx, plan.AppAction.Desired.Region, appKey); err != nil {
|
||||
result.FailedActions = append(result.FailedActions, ActionResult{
|
||||
Type: ActionDelete,
|
||||
Target: plan.AppAction.Desired.Name,
|
||||
|
|
@ -386,7 +389,12 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action
|
|||
}
|
||||
}
|
||||
|
||||
success, err := r.updateApplication(ctx, action, config, manifestContent)
|
||||
var success bool
|
||||
var err error
|
||||
|
||||
// For recreate strategy, we always create the app
|
||||
success, err = r.createApplication(ctx, action, config, manifestContent)
|
||||
|
||||
if success {
|
||||
result.Success = true
|
||||
result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name)
|
||||
|
|
@ -407,16 +415,16 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action
|
|||
|
||||
// deleteInstance deletes an instance (reuse existing logic from manager.go)
|
||||
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
instanceKey := domain.AppInstanceKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.InstanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
CloudletKey: domain.CloudletKey{
|
||||
Organization: action.Target.CloudletOrg,
|
||||
Name: action.Target.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
err := r.client.DeleteAppInstance(ctx, instanceKey, action.Target.Region)
|
||||
err := r.appInstRepo.DeleteAppInstance(ctx, action.Target.Region, instanceKey)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to delete instance: %w", err)
|
||||
}
|
||||
|
|
@ -426,30 +434,27 @@ func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAc
|
|||
|
||||
// createInstance creates an instance (extracted from manager.go logic)
|
||||
func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
|
||||
instanceInput := &edgeconnect.NewAppInstanceInput{
|
||||
Region: action.Target.Region,
|
||||
AppInst: edgeconnect.AppInstance{
|
||||
Key: edgeconnect.AppInstanceKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.InstanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: action.Target.CloudletOrg,
|
||||
Name: action.Target.CloudletName,
|
||||
},
|
||||
},
|
||||
AppKey: edgeconnect.AppKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Metadata.AppVersion,
|
||||
},
|
||||
Flavor: edgeconnect.Flavor{
|
||||
Name: action.Target.FlavorName,
|
||||
appInst := &domain.AppInstance{
|
||||
Key: domain.AppInstanceKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.InstanceName,
|
||||
CloudletKey: domain.CloudletKey{
|
||||
Organization: action.Target.CloudletOrg,
|
||||
Name: action.Target.CloudletName,
|
||||
},
|
||||
},
|
||||
AppKey: domain.AppKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Metadata.AppVersion,
|
||||
},
|
||||
Flavor: domain.Flavor{
|
||||
Name: action.Target.FlavorName,
|
||||
},
|
||||
}
|
||||
|
||||
// Create the instance
|
||||
if err := r.client.CreateAppInstance(ctx, instanceInput); err != nil {
|
||||
if err := r.appInstRepo.CreateAppInstance(ctx, action.Target.Region, appInst); err != nil {
|
||||
return false, fmt.Errorf("failed to create instance: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -459,35 +464,30 @@ func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAc
|
|||
return true, nil
|
||||
}
|
||||
|
||||
// updateApplication creates/recreates an application (always uses CreateApp since we delete first)
|
||||
func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) {
|
||||
// Build the app create input - always create since recreate strategy deletes first
|
||||
appInput := &edgeconnect.NewAppInput{
|
||||
Region: action.Desired.Region,
|
||||
App: edgeconnect.App{
|
||||
Key: edgeconnect.AppKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.Desired.Name,
|
||||
Version: action.Desired.Version,
|
||||
},
|
||||
Deployment: config.GetDeploymentType(),
|
||||
ImageType: "ImageTypeDocker",
|
||||
ImagePath: config.GetImagePath(),
|
||||
AllowServerless: true,
|
||||
DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
|
||||
ServerlessConfig: struct{}{},
|
||||
DeploymentManifest: manifestContent,
|
||||
DeploymentGenerator: "kubernetes-basic",
|
||||
// createApplication creates/recreates an application (always uses CreateApp since we delete first)
|
||||
func (r *RecreateStrategy) createApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) {
|
||||
app := &domain.App{
|
||||
Key: domain.AppKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.Desired.Name,
|
||||
Version: action.Desired.Version,
|
||||
},
|
||||
Deployment: config.GetDeploymentType(),
|
||||
ImagePath: config.GetImagePath(),
|
||||
AllowServerless: true,
|
||||
DefaultFlavor: domain.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
|
||||
ServerlessConfig: struct{}{},
|
||||
DeploymentManifest: manifestContent,
|
||||
DeploymentGenerator: "kubernetes-basic",
|
||||
}
|
||||
|
||||
// Add network configuration if specified
|
||||
if config.Spec.Network != nil {
|
||||
appInput.App.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network)
|
||||
app.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network)
|
||||
}
|
||||
|
||||
// Create the application (recreate strategy always creates from scratch)
|
||||
if err := r.client.CreateApp(ctx, appInput); err != nil {
|
||||
if err := r.appRepo.CreateApp(ctx, action.Desired.Region, app); err != nil {
|
||||
return false, fmt.Errorf("failed to create application: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -497,9 +497,27 @@ func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppActi
|
|||
return true, nil
|
||||
}
|
||||
|
||||
// convertNetworkRules converts config.NetworkConfig to []domain.SecurityRule
|
||||
func convertNetworkRules(network *config.NetworkConfig) []domain.SecurityRule {
|
||||
if network == nil || len(network.OutboundConnections) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rules := make([]domain.SecurityRule, len(network.OutboundConnections))
|
||||
for i, conn := range network.OutboundConnections {
|
||||
rules[i] = domain.SecurityRule{
|
||||
Protocol: conn.Protocol,
|
||||
PortRangeMin: conn.PortRangeMin,
|
||||
PortRangeMax: conn.PortRangeMax,
|
||||
RemoteCIDR: conn.RemoteCIDR,
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// logf logs a message if a logger is configured
|
||||
func (r *RecreateStrategy) logf(format string, v ...interface{}) {
|
||||
if r.logger != nil {
|
||||
r.logger.Printf("[RecreateStrategy] "+format, v...)
|
||||
}
|
||||
}
|
||||
}
|
||||
161
internal/application/apply/types.go
Normal file
161
internal/application/apply/types.go
Normal 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
|
||||
}
|
||||
118
internal/application/cloudlet/service.go
Normal file
118
internal/application/cloudlet/service.go
Normal 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
|
||||
}
|
||||
185
internal/application/instance/service.go
Normal file
185
internal/application/instance/service.go
Normal 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
|
||||
}
|
||||
63
internal/application/organization/service.go
Normal file
63
internal/application/organization/service.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
31
internal/domain/app.go
Normal 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
|
||||
}
|
||||
18
internal/domain/app_instance.go
Normal file
18
internal/domain/app_instance.go
Normal 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
|
||||
}
|
||||
26
internal/domain/cloudlet.go
Normal file
26
internal/domain/cloudlet.go
Normal 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
309
internal/domain/errors.go
Normal 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
|
||||
}
|
||||
207
internal/domain/errors_test.go
Normal file
207
internal/domain/errors_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
6
internal/domain/flavor.go
Normal file
6
internal/domain/flavor.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package domain
|
||||
|
||||
// Flavor defines resource allocation for instances
|
||||
type Flavor struct {
|
||||
Name string
|
||||
}
|
||||
9
internal/domain/organization.go
Normal file
9
internal/domain/organization.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -11,56 +10,47 @@ import (
|
|||
)
|
||||
|
||||
func TestParseExampleConfig(t *testing.T) {
|
||||
// The base path is relative to the location of this test file
|
||||
parser := NewParser()
|
||||
|
||||
// Parse the actual example file (now that we've created the manifest file)
|
||||
examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig.yaml")
|
||||
config, parsedManifest, err := parser.ParseFile(examplePath)
|
||||
|
||||
// This should now succeed with full validation
|
||||
cfg, _, err := parser.ParseFile("../../../sdk/examples/comprehensive/EdgeConnectConfig.yaml")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
require.NotEmpty(t, parsedManifest)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Validate the parsed structure
|
||||
assert.Equal(t, "edgeconnect-deployment", config.Kind)
|
||||
assert.Equal(t, "edge-app-demo", config.Metadata.Name)
|
||||
|
||||
// Check k8s app configuration
|
||||
require.NotNil(t, config.Spec.K8sApp)
|
||||
assert.Equal(t, "1.0.0", config.Metadata.AppVersion)
|
||||
// Note: ManifestFile path should be resolved to absolute path
|
||||
assert.Contains(t, config.Spec.K8sApp.ManifestFile, "k8s-deployment.yaml")
|
||||
// Basic validation
|
||||
assert.Equal(t, "edgeconnect-deployment", cfg.Kind)
|
||||
assert.Equal(t, "edge-app-demo", cfg.Metadata.Name)
|
||||
assert.NotNil(t, cfg.Spec.K8sApp)
|
||||
assert.NotEmpty(t, cfg.Spec.K8sApp.ManifestFile)
|
||||
|
||||
// Check infrastructure template
|
||||
require.Len(t, config.Spec.InfraTemplate, 1)
|
||||
infra := config.Spec.InfraTemplate[0]
|
||||
require.Len(t, cfg.Spec.InfraTemplate, 1)
|
||||
infra := cfg.Spec.InfraTemplate[0]
|
||||
assert.Equal(t, "EU", infra.Region)
|
||||
assert.Equal(t, "TelekomOP", infra.CloudletOrg)
|
||||
assert.Equal(t, "Munich", infra.CloudletName)
|
||||
assert.Equal(t, "EU.small", infra.FlavorName)
|
||||
|
||||
// Check network configuration
|
||||
require.NotNil(t, config.Spec.Network)
|
||||
require.Len(t, config.Spec.Network.OutboundConnections, 2)
|
||||
require.NotNil(t, cfg.Spec.Network)
|
||||
require.Len(t, cfg.Spec.Network.OutboundConnections, 2)
|
||||
|
||||
conn1 := config.Spec.Network.OutboundConnections[0]
|
||||
conn1 := cfg.Spec.Network.OutboundConnections[0]
|
||||
assert.Equal(t, "tcp", conn1.Protocol)
|
||||
assert.Equal(t, 80, conn1.PortRangeMin)
|
||||
assert.Equal(t, 80, conn1.PortRangeMax)
|
||||
assert.Equal(t, "0.0.0.0/0", conn1.RemoteCIDR)
|
||||
|
||||
conn2 := config.Spec.Network.OutboundConnections[1]
|
||||
conn2 := cfg.Spec.Network.OutboundConnections[1]
|
||||
assert.Equal(t, "tcp", conn2.Protocol)
|
||||
assert.Equal(t, 443, conn2.PortRangeMin)
|
||||
assert.Equal(t, 443, conn2.PortRangeMax)
|
||||
assert.Equal(t, "0.0.0.0/0", conn2.RemoteCIDR)
|
||||
|
||||
// Test utility methods
|
||||
assert.Equal(t, "edge-app-demo", config.Metadata.Name)
|
||||
assert.Contains(t, config.Spec.GetManifestFile(), "k8s-deployment.yaml")
|
||||
assert.True(t, config.Spec.IsK8sApp())
|
||||
assert.False(t, config.Spec.IsDockerApp())
|
||||
assert.Equal(t, "edge-app-demo", cfg.Metadata.Name)
|
||||
assert.Contains(t, cfg.Spec.GetManifestFile(), "k8s-deployment.yaml")
|
||||
assert.True(t, cfg.Spec.IsK8sApp())
|
||||
assert.False(t, cfg.Spec.IsDockerApp())
|
||||
}
|
||||
|
||||
func TestValidateExampleStructure(t *testing.T) {
|
||||
|
|
@ -70,13 +60,13 @@ func TestValidateExampleStructure(t *testing.T) {
|
|||
config := &EdgeConnectConfig{
|
||||
Kind: "edgeconnect-deployment",
|
||||
Metadata: Metadata{
|
||||
Name: "edge-app-demo",
|
||||
AppVersion: "1.0.0",
|
||||
Name: "edge-app-demo",
|
||||
AppVersion: "1.0.0",
|
||||
Organization: "edp2",
|
||||
},
|
||||
Spec: Spec{
|
||||
DockerApp: &DockerApp{ // Use DockerApp to avoid manifest file validation
|
||||
Image: "nginx:latest",
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
InfraTemplate: []InfraTemplate{
|
||||
{
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// ABOUTME: Authentication providers for EdgeXR Master Controller API
|
||||
// ABOUTME: Supports Bearer token authentication with pluggable provider interface
|
||||
|
||||
package edgeconnect
|
||||
package edgeconnect_client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -138,7 +139,12 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
// Can't use c.logf here since this is in auth provider
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to close auth response body: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Read response body - same as existing implementation
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
353
internal/infrastructure/edgeconnect_client/client.go
Normal file
353
internal/infrastructure/edgeconnect_client/client.go
Normal 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
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
// ABOUTME: Core type definitions for EdgeXR Master Controller SDK
|
||||
// ABOUTME: These types are based on the swagger API specification and existing client patterns
|
||||
|
||||
package edgeconnect
|
||||
package edgeconnect_client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
|
||||
)
|
||||
|
||||
// App field constants for partial updates (based on EdgeXR API specification)
|
||||
|
|
@ -358,4 +360,4 @@ type CloudletResourceUsage struct {
|
|||
CloudletKey CloudletKey `json:"cloudlet_key"`
|
||||
Region string `json:"region"`
|
||||
Usage map[string]interface{} `json:"usage"`
|
||||
}
|
||||
}
|
||||
28
internal/infrastructure/transport/parser.go
Normal file
28
internal/infrastructure/transport/parser.go
Normal 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
|
||||
}
|
||||
137
internal/infrastructure/transport/transport.go
Normal file
137
internal/infrastructure/transport/transport.go
Normal 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...)
|
||||
}
|
||||
}
|
||||
14
internal/ports/driven/app_repository.go
Normal file
14
internal/ports/driven/app_repository.go
Normal 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
|
||||
}
|
||||
13
internal/ports/driven/cloudlet_repository.go
Normal file
13
internal/ports/driven/cloudlet_repository.go
Normal 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
|
||||
}
|
||||
15
internal/ports/driven/instance_repository.go
Normal file
15
internal/ports/driven/instance_repository.go
Normal 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
|
||||
}
|
||||
20
internal/ports/driven/organization_repository.go
Normal file
20
internal/ports/driven/organization_repository.go
Normal 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
|
||||
}
|
||||
14
internal/ports/driving/app_service.go
Normal file
14
internal/ports/driving/app_service.go
Normal 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
|
||||
}
|
||||
13
internal/ports/driving/cloudlet_service.go
Normal file
13
internal/ports/driving/cloudlet_service.go
Normal 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
|
||||
}
|
||||
15
internal/ports/driving/instance_service.go
Normal file
15
internal/ports/driving/instance_service.go
Normal 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
|
||||
}
|
||||
16
internal/ports/driving/organization_service.go
Normal file
16
internal/ports/driving/organization_service.go
Normal 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
|
||||
}
|
||||
7
main.go
7
main.go
|
|
@ -1,7 +0,0 @@
|
|||
package main
|
||||
|
||||
import "edp.buildth.ing/DevFW-CICD/edge-connect-client/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
|
|
@ -125,14 +125,14 @@ client := client.NewClient(baseURL,
|
|||
### Simple App Deployment
|
||||
```bash
|
||||
# Run basic example
|
||||
EDGEXR_USERNAME=user EDGEXR_PASSWORD=pass go run sdk/examples/deploy_app.go
|
||||
EDGE_CONNECT_USERNAME=user EDGE_CONNECT_PASSWORD=pass go run sdk/examples/deploy_app.go
|
||||
```
|
||||
|
||||
### Comprehensive Workflow
|
||||
```bash
|
||||
# Run full workflow demonstration
|
||||
cd sdk/examples/comprehensive
|
||||
EDGEXR_USERNAME=user EDGEXR_PASSWORD=pass go run main.go
|
||||
EDGE_CONNECT_USERNAME=user EDGE_CONNECT_PASSWORD=pass go run main.go
|
||||
```
|
||||
|
||||
## Authentication Methods
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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...)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -9,40 +9,43 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Configure SDK client
|
||||
baseURL := getEnvOrDefault("EDGEXR_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live")
|
||||
baseURL := getEnvOrDefault("EDGE_CONNECT_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live")
|
||||
|
||||
// Support both authentication methods
|
||||
token := getEnvOrDefault("EDGEXR_TOKEN", "")
|
||||
username := getEnvOrDefault("EDGEXR_USERNAME", "")
|
||||
password := getEnvOrDefault("EDGEXR_PASSWORD", "")
|
||||
token := getEnvOrDefault("EDGE_CONNECT_TOKEN", "")
|
||||
username := getEnvOrDefault("EDGE_CONNECT_USERNAME", "")
|
||||
password := getEnvOrDefault("EDGE_CONNECT_PASSWORD", "")
|
||||
|
||||
var client *edgeconnect.Client
|
||||
var client *edgeconnect_client.Client
|
||||
|
||||
if token != "" {
|
||||
fmt.Println("🔐 Using Bearer token authentication")
|
||||
client = edgeconnect.NewClient(baseURL,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)),
|
||||
edgeconnect.WithLogger(log.Default()),
|
||||
client = edgeconnect_client.NewClient(baseURL,
|
||||
edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)),
|
||||
edgeconnect_client.WithLogger(log.Default()),
|
||||
)
|
||||
} else if username != "" && password != "" {
|
||||
fmt.Println("🔐 Using username/password authentication")
|
||||
client = edgeconnect.NewClientWithCredentials(baseURL, username, password,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect.WithLogger(log.Default()),
|
||||
client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password,
|
||||
edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect_client.WithLogger(log.Default()),
|
||||
)
|
||||
} else {
|
||||
log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD")
|
||||
log.Fatal("Authentication required: Set either EDGE_CONNECT_TOKEN or both EDGE_CONNECT_USERNAME and EDGE_CONNECT_PASSWORD")
|
||||
}
|
||||
|
||||
adapter := edgeconnect.NewAdapter(client)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Configuration for the workflow
|
||||
|
|
@ -61,7 +64,7 @@ func main() {
|
|||
fmt.Printf("Organization: %s, Region: %s\n\n", config.Organization, config.Region)
|
||||
|
||||
// Run the complete workflow
|
||||
if err := runComprehensiveWorkflow(ctx, client, config); err != nil {
|
||||
if err := runComprehensiveWorkflow(ctx, adapter, config); err != nil {
|
||||
log.Fatalf("Workflow failed: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -85,15 +88,19 @@ type WorkflowConfig struct {
|
|||
FlavorName string
|
||||
}
|
||||
|
||||
func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config WorkflowConfig) error {
|
||||
func runComprehensiveWorkflow(ctx context.Context, adapter *edgeconnect.Adapter, config WorkflowConfig) error {
|
||||
var domainAppKey domain.AppKey
|
||||
var domainInstanceKey domain.AppInstanceKey
|
||||
var domainCloudletKey domain.CloudletKey
|
||||
var domainAppInstKey domain.AppInstanceKey
|
||||
fmt.Println("═══ Phase 1: Application Management ═══")
|
||||
|
||||
// 1. Create Application
|
||||
fmt.Println("\n1️⃣ Creating application...")
|
||||
app := &edgeconnect.NewAppInput{
|
||||
app := &NewAppInput{
|
||||
Region: config.Region,
|
||||
App: edgeconnect.App{
|
||||
Key: edgeconnect.AppKey{
|
||||
App: App{
|
||||
Key: AppKey{
|
||||
Organization: config.Organization,
|
||||
Name: config.AppName,
|
||||
Version: config.AppVersion,
|
||||
|
|
@ -101,10 +108,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
Deployment: "kubernetes",
|
||||
ImageType: "ImageTypeDocker", // field is ignored
|
||||
ImagePath: "https://registry-1.docker.io/library/nginx:latest", // must be set. Even for kubernetes
|
||||
DefaultFlavor: edgeconnect.Flavor{Name: config.FlavorName},
|
||||
DefaultFlavor: Flavor{Name: config.FlavorName},
|
||||
ServerlessConfig: struct{}{}, // must be set
|
||||
AllowServerless: true, // must be set to true for kubernetes
|
||||
RequiredOutboundConnections: []edgeconnect.SecurityRule{
|
||||
RequiredOutboundConnections: []SecurityRule{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
|
|
@ -121,20 +128,40 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
},
|
||||
}
|
||||
|
||||
if err := c.CreateApp(ctx, app); err != nil {
|
||||
domainApp := &domain.App{
|
||||
Key: domain.AppKey{
|
||||
Organization: app.App.Key.Organization,
|
||||
Name: app.App.Key.Name,
|
||||
Version: app.App.Key.Version,
|
||||
},
|
||||
Deployment: app.App.Deployment,
|
||||
ImageType: app.App.ImageType,
|
||||
ImagePath: app.App.ImagePath,
|
||||
DefaultFlavor: domain.Flavor{Name: app.App.DefaultFlavor.Name},
|
||||
ServerlessConfig: app.App.ServerlessConfig,
|
||||
AllowServerless: app.App.AllowServerless,
|
||||
RequiredOutboundConnections: toDomainSecurityRules(app.App.RequiredOutboundConnections),
|
||||
}
|
||||
|
||||
if err := adapter.CreateApp(ctx, app.Region, domainApp); err != nil {
|
||||
return fmt.Errorf("failed to create app: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ App created: %s/%s v%s\n", config.Organization, config.AppName, config.AppVersion)
|
||||
|
||||
// 2. Show Application Details
|
||||
fmt.Println("\n2️⃣ Querying application details...")
|
||||
appKey := edgeconnect.AppKey{
|
||||
appKey := AppKey{
|
||||
Organization: config.Organization,
|
||||
Name: config.AppName,
|
||||
Version: config.AppVersion,
|
||||
}
|
||||
|
||||
appDetails, err := c.ShowApp(ctx, appKey, config.Region)
|
||||
domainAppKey = domain.AppKey{
|
||||
Organization: appKey.Organization,
|
||||
Name: appKey.Name,
|
||||
Version: appKey.Version,
|
||||
}
|
||||
appDetails, err := adapter.ShowApp(ctx, config.Region, domainAppKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to show app: %w", err)
|
||||
}
|
||||
|
|
@ -146,8 +173,8 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 3. List Applications in Organization
|
||||
fmt.Println("\n3️⃣ Listing applications in organization...")
|
||||
filter := edgeconnect.AppKey{Organization: config.Organization}
|
||||
apps, err := c.ShowApps(ctx, filter, config.Region)
|
||||
filter := domain.AppKey{Organization: config.Organization}
|
||||
apps, err := adapter.ShowApps(ctx, config.Region, filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list apps: %w", err)
|
||||
}
|
||||
|
|
@ -160,23 +187,39 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 4. Create Application Instance
|
||||
fmt.Println("\n4️⃣ Creating application instance...")
|
||||
instance := &edgeconnect.NewAppInstanceInput{
|
||||
instance := &NewAppInstanceInput{
|
||||
Region: config.Region,
|
||||
AppInst: edgeconnect.AppInstance{
|
||||
Key: edgeconnect.AppInstanceKey{
|
||||
AppInst: AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: config.Organization,
|
||||
Name: config.InstanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: config.CloudletOrg,
|
||||
Name: config.CloudletName,
|
||||
},
|
||||
},
|
||||
AppKey: appKey,
|
||||
Flavor: edgeconnect.Flavor{Name: config.FlavorName},
|
||||
Flavor: Flavor{Name: config.FlavorName},
|
||||
},
|
||||
}
|
||||
|
||||
if err := c.CreateAppInstance(ctx, instance); err != nil {
|
||||
domainAppInst := &domain.AppInstance{
|
||||
Key: domain.AppInstanceKey{
|
||||
Organization: instance.AppInst.Key.Organization,
|
||||
Name: instance.AppInst.Key.Name,
|
||||
CloudletKey: domain.CloudletKey{
|
||||
Organization: instance.AppInst.Key.CloudletKey.Organization,
|
||||
Name: instance.AppInst.Key.CloudletKey.Name,
|
||||
},
|
||||
},
|
||||
AppKey: domain.AppKey{
|
||||
Organization: instance.AppInst.AppKey.Organization,
|
||||
Name: instance.AppInst.AppKey.Name,
|
||||
Version: instance.AppInst.AppKey.Version,
|
||||
},
|
||||
Flavor: domain.Flavor{Name: instance.AppInst.Flavor.Name},
|
||||
}
|
||||
if err := adapter.CreateAppInstance(ctx, instance.Region, domainAppInst); err != nil {
|
||||
return fmt.Errorf("failed to create app instance: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ App instance created: %s on cloudlet %s/%s\n",
|
||||
|
|
@ -184,16 +227,16 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 5. Wait for Application Instance to be Ready
|
||||
fmt.Println("\n5️⃣ Waiting for application instance to be ready...")
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
instanceKey := AppInstanceKey{
|
||||
Organization: config.Organization,
|
||||
Name: config.InstanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: config.CloudletOrg,
|
||||
Name: config.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, config.Region, 5*time.Minute)
|
||||
instanceDetails, err := waitForInstanceReady(ctx, adapter, instanceKey, config.Region, 5*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to wait for instance ready: %w", err)
|
||||
}
|
||||
|
|
@ -207,7 +250,8 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 6. List Application Instances
|
||||
fmt.Println("\n6️⃣ Listing application instances...")
|
||||
instances, err := c.ShowAppInstances(ctx, edgeconnect.AppInstanceKey{Organization: config.Organization}, config.Region)
|
||||
domainAppInstKey = domain.AppInstanceKey{Organization: config.Organization}
|
||||
instances, err := adapter.ShowAppInstances(ctx, config.Region, domainAppInstKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list app instances: %w", err)
|
||||
}
|
||||
|
|
@ -219,7 +263,15 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 7. Refresh Application Instance
|
||||
fmt.Println("\n7️⃣ Refreshing application instance...")
|
||||
if err := c.RefreshAppInstance(ctx, instanceKey, config.Region); err != nil {
|
||||
domainInstanceKey = domain.AppInstanceKey{
|
||||
Organization: instanceKey.Organization,
|
||||
Name: instanceKey.Name,
|
||||
CloudletKey: domain.CloudletKey{
|
||||
Organization: instanceKey.CloudletKey.Organization,
|
||||
Name: instanceKey.CloudletKey.Name,
|
||||
},
|
||||
}
|
||||
if err := adapter.RefreshAppInstance(ctx, config.Region, domainInstanceKey); err != nil {
|
||||
return fmt.Errorf("failed to refresh app instance: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ Instance refreshed: %s\n", config.InstanceName)
|
||||
|
|
@ -228,12 +280,16 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 8. Show Cloudlet Details
|
||||
fmt.Println("\n8️⃣ Querying cloudlet information...")
|
||||
cloudletKey := edgeconnect.CloudletKey{
|
||||
cloudletKey := CloudletKey{
|
||||
Organization: config.CloudletOrg,
|
||||
Name: config.CloudletName,
|
||||
}
|
||||
|
||||
cloudlets, err := c.ShowCloudlets(ctx, cloudletKey, config.Region)
|
||||
domainCloudletKey = domain.CloudletKey{
|
||||
Organization: cloudletKey.Organization,
|
||||
Name: cloudletKey.Name,
|
||||
}
|
||||
cloudlets, err := adapter.ShowCloudlets(ctx, config.Region, domainCloudletKey)
|
||||
if err != nil {
|
||||
// This might fail in demo environment, so we'll continue
|
||||
fmt.Printf("⚠️ Could not retrieve cloudlet details: %v\n", err)
|
||||
|
|
@ -249,7 +305,11 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 9. Try to Get Cloudlet Manifest (may not be available in demo)
|
||||
fmt.Println("\n9️⃣ Attempting to retrieve cloudlet manifest...")
|
||||
manifest, err := c.GetCloudletManifest(ctx, cloudletKey, config.Region)
|
||||
domainCloudletKey = domain.CloudletKey{
|
||||
Organization: cloudletKey.Organization,
|
||||
Name: cloudletKey.Name,
|
||||
}
|
||||
manifest, err := adapter.GetCloudletManifest(ctx, domainCloudletKey, config.Region)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ Could not retrieve cloudlet manifest: %v\n", err)
|
||||
} else {
|
||||
|
|
@ -258,7 +318,11 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 10. Try to Get Cloudlet Resource Usage (may not be available in demo)
|
||||
fmt.Println("\n🔟 Attempting to retrieve cloudlet resource usage...")
|
||||
usage, err := c.GetCloudletResourceUsage(ctx, cloudletKey, config.Region)
|
||||
domainCloudletKey = domain.CloudletKey{
|
||||
Organization: cloudletKey.Organization,
|
||||
Name: cloudletKey.Name,
|
||||
}
|
||||
usage, err := adapter.GetCloudletResourceUsage(ctx, domainCloudletKey, config.Region)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ Could not retrieve cloudlet usage: %v\n", err)
|
||||
} else {
|
||||
|
|
@ -272,22 +336,40 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 11. Delete Application Instance
|
||||
fmt.Println("\n1️⃣1️⃣ Cleaning up application instance...")
|
||||
if err := c.DeleteAppInstance(ctx, instanceKey, config.Region); err != nil {
|
||||
domainInstanceKey = domain.AppInstanceKey{
|
||||
Organization: instanceKey.Organization,
|
||||
Name: instanceKey.Name,
|
||||
CloudletKey: domain.CloudletKey{
|
||||
Organization: instanceKey.CloudletKey.Organization,
|
||||
Name: instanceKey.CloudletKey.Name,
|
||||
},
|
||||
}
|
||||
if err := adapter.DeleteAppInstance(ctx, config.Region, domainInstanceKey); err != nil {
|
||||
return fmt.Errorf("failed to delete app instance: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ App instance deleted: %s\n", config.InstanceName)
|
||||
|
||||
// 12. Delete Application
|
||||
fmt.Println("\n1️⃣2️⃣ Cleaning up application...")
|
||||
if err := c.DeleteApp(ctx, appKey, config.Region); err != nil {
|
||||
domainAppKey = domain.AppKey{
|
||||
Organization: appKey.Organization,
|
||||
Name: appKey.Name,
|
||||
Version: appKey.Version,
|
||||
}
|
||||
if err := adapter.DeleteApp(ctx, config.Region, domainAppKey); err != nil {
|
||||
return fmt.Errorf("failed to delete app: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ App deleted: %s/%s v%s\n", config.Organization, config.AppName, config.AppVersion)
|
||||
|
||||
// 13. Verify Cleanup
|
||||
fmt.Println("\n1️⃣3️⃣ Verifying cleanup...")
|
||||
_, err = c.ShowApp(ctx, appKey, config.Region)
|
||||
if err != nil && fmt.Sprintf("%v", err) == edgeconnect.ErrResourceNotFound.Error() {
|
||||
domainAppKey = domain.AppKey{
|
||||
Organization: appKey.Organization,
|
||||
Name: appKey.Name,
|
||||
Version: appKey.Version,
|
||||
}
|
||||
_, err = adapter.ShowApp(ctx, config.Region, domainAppKey)
|
||||
if err != nil && domain.IsNotFoundError(err) {
|
||||
fmt.Printf("✅ Cleanup verified - app no longer exists\n")
|
||||
} else if err != nil {
|
||||
fmt.Printf("✅ Cleanup appears successful (verification returned: %v)\n", err)
|
||||
|
|
@ -306,7 +388,7 @@ func getEnvOrDefault(key, defaultValue string) string {
|
|||
}
|
||||
|
||||
// waitForInstanceReady polls the instance status until it's no longer "Creating" or timeout
|
||||
func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKey edgeconnect.AppInstanceKey, region string, timeout time.Duration) (edgeconnect.AppInstance, error) {
|
||||
func waitForInstanceReady(ctx context.Context, adapter *edgeconnect.Adapter, instanceKey AppInstanceKey, region string, timeout time.Duration) (AppInstance, error) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -318,36 +400,128 @@ func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKe
|
|||
for {
|
||||
select {
|
||||
case <-timeoutCtx.Done():
|
||||
return edgeconnect.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout)
|
||||
|
||||
return AppInstance{}, fmt.Errorf("timed out waiting for instance to be ready")
|
||||
case <-ticker.C:
|
||||
instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, region)
|
||||
domainInstanceKey := domain.AppInstanceKey{
|
||||
Organization: instanceKey.Organization,
|
||||
Name: instanceKey.Name,
|
||||
CloudletKey: domain.CloudletKey{
|
||||
Organization: instanceKey.CloudletKey.Organization,
|
||||
Name: instanceKey.CloudletKey.Name,
|
||||
},
|
||||
}
|
||||
instance, err := adapter.ShowAppInstance(ctx, region, domainInstanceKey)
|
||||
if err != nil {
|
||||
// Log error but continue polling
|
||||
fmt.Printf(" ⚠️ Error checking instance state: %v\n", err)
|
||||
// Continue polling on transient errors
|
||||
fmt.Printf(" (polling) transient error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(" 📊 Instance state: %s", instance.State)
|
||||
if instance.PowerState != "" {
|
||||
fmt.Printf(" (power: %s)", instance.PowerState)
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
|
||||
// Check if instance is ready (not in creating state)
|
||||
state := strings.ToLower(instance.State)
|
||||
if state != "" && state != "creating" && state != "create requested" {
|
||||
if state == "ready" || state == "running" {
|
||||
fmt.Printf(" ✅ Instance reached ready state: %s\n", instance.State)
|
||||
return instance, nil
|
||||
} else if state == "error" || state == "failed" || strings.Contains(state, "error") {
|
||||
return instance, fmt.Errorf("instance entered error state: %s", instance.State)
|
||||
} else {
|
||||
// Instance is in some other stable state (not creating)
|
||||
fmt.Printf(" ✅ Instance reached stable state: %s\n", instance.State)
|
||||
return instance, nil
|
||||
// Check for a terminal state
|
||||
if instance.State != "Creating" && instance.State != "Updating" {
|
||||
if instance.State == "Ready" {
|
||||
return fromDomainAppInstance(instance), nil
|
||||
}
|
||||
return AppInstance{}, fmt.Errorf("instance entered a non-ready terminal state: %s", instance.State)
|
||||
}
|
||||
fmt.Printf(" (polling) current state: %s...\n", instance.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The structs below are included to make the example self-contained and runnable.
|
||||
// In a real application, these would be defined in the `edgeconnect` package.
|
||||
|
||||
type NewAppInput struct {
|
||||
Region string
|
||||
App App
|
||||
RequiredOutboundConnections []SecurityRule
|
||||
}
|
||||
|
||||
type App struct {
|
||||
Key AppKey
|
||||
Deployment string
|
||||
ImageType string
|
||||
ImagePath string
|
||||
DefaultFlavor Flavor
|
||||
ServerlessConfig interface{}
|
||||
AllowServerless bool
|
||||
RequiredOutboundConnections []SecurityRule
|
||||
}
|
||||
|
||||
type AppKey struct {
|
||||
Organization string
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
type Flavor struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type SecurityRule struct {
|
||||
Protocol string
|
||||
PortRangeMin int
|
||||
PortRangeMax int
|
||||
RemoteCIDR string
|
||||
}
|
||||
|
||||
type NewAppInstanceInput struct {
|
||||
Region string
|
||||
AppInst AppInstance
|
||||
}
|
||||
|
||||
type AppInstance struct {
|
||||
Key AppInstanceKey
|
||||
AppKey AppKey
|
||||
Flavor Flavor
|
||||
State string
|
||||
PowerState string
|
||||
}
|
||||
|
||||
type AppInstanceKey struct {
|
||||
Organization string
|
||||
Name string
|
||||
CloudletKey CloudletKey
|
||||
}
|
||||
|
||||
type CloudletKey struct {
|
||||
Organization string
|
||||
Name string
|
||||
}
|
||||
|
||||
func toDomainSecurityRules(rules []SecurityRule) []domain.SecurityRule {
|
||||
domainRules := make([]domain.SecurityRule, len(rules))
|
||||
for i, r := range rules {
|
||||
domainRules[i] = domain.SecurityRule{
|
||||
Protocol: r.Protocol,
|
||||
PortRangeMin: r.PortRangeMin,
|
||||
PortRangeMax: r.PortRangeMax,
|
||||
RemoteCIDR: r.RemoteCIDR,
|
||||
}
|
||||
}
|
||||
return domainRules
|
||||
}
|
||||
|
||||
func fromDomainAppInstance(d *domain.AppInstance) AppInstance {
|
||||
return AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: d.Key.Organization,
|
||||
Name: d.Key.Name,
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: d.Key.CloudletKey.Organization,
|
||||
Name: d.Key.CloudletKey.Name,
|
||||
},
|
||||
},
|
||||
AppKey: AppKey{
|
||||
Organization: d.AppKey.Organization,
|
||||
Name: d.AppKey.Name,
|
||||
Version: d.AppKey.Version,
|
||||
},
|
||||
Flavor: Flavor{
|
||||
Name: d.Flavor.Name,
|
||||
},
|
||||
State: d.State,
|
||||
PowerState: d.PowerState,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,49 +9,52 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Configure SDK client
|
||||
baseURL := getEnvOrDefault("EDGEXR_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live")
|
||||
baseURL := getEnvOrDefault("EDGE_CONNECT_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live")
|
||||
|
||||
// Support both token-based and username/password authentication
|
||||
token := getEnvOrDefault("EDGEXR_TOKEN", "")
|
||||
username := getEnvOrDefault("EDGEXR_USERNAME", "")
|
||||
password := getEnvOrDefault("EDGEXR_PASSWORD", "")
|
||||
token := getEnvOrDefault("EDGE_CONNECT_TOKEN", "")
|
||||
username := getEnvOrDefault("EDGE_CONNECT_USERNAME", "")
|
||||
password := getEnvOrDefault("EDGE_CONNECT_PASSWORD", "")
|
||||
|
||||
var edgeClient *edgeconnect.Client
|
||||
var client *edgeconnect_client.Client
|
||||
|
||||
if token != "" {
|
||||
// Use static token authentication
|
||||
fmt.Println("🔐 Using Bearer token authentication")
|
||||
edgeClient = edgeconnect.NewClient(baseURL,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)),
|
||||
edgeconnect.WithLogger(log.Default()),
|
||||
client = edgeconnect_client.NewClient(baseURL,
|
||||
edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)),
|
||||
edgeconnect_client.WithLogger(log.Default()),
|
||||
)
|
||||
} else if username != "" && password != "" {
|
||||
// Use username/password authentication (matches existing client pattern)
|
||||
fmt.Println("🔐 Using username/password authentication")
|
||||
edgeClient = edgeconnect.NewClientWithCredentials(baseURL, username, password,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect.WithLogger(log.Default()),
|
||||
client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password,
|
||||
edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect_client.WithLogger(log.Default()),
|
||||
)
|
||||
} else {
|
||||
log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD")
|
||||
log.Fatal("Authentication required: Set either EDGE_CONNECT_TOKEN or both EDGE_CONNECT_USERNAME and EDGE_CONNECT_PASSWORD")
|
||||
}
|
||||
|
||||
adapter := edgeconnect.NewAdapter(client)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Example application to deploy
|
||||
app := &edgeconnect.NewAppInput{
|
||||
app := &NewAppInput{
|
||||
Region: "EU",
|
||||
App: edgeconnect.App{
|
||||
Key: edgeconnect.AppKey{
|
||||
App: App{
|
||||
Key: AppKey{
|
||||
Organization: "edp2",
|
||||
Name: "my-edge-app",
|
||||
Version: "1.0.0",
|
||||
|
|
@ -59,78 +62,115 @@ func main() {
|
|||
Deployment: "docker",
|
||||
ImageType: "ImageTypeDocker",
|
||||
ImagePath: "https://registry-1.docker.io/library/nginx:latest",
|
||||
DefaultFlavor: edgeconnect.Flavor{Name: "EU.small"},
|
||||
DefaultFlavor: Flavor{Name: "EU.small"},
|
||||
ServerlessConfig: struct{}{},
|
||||
AllowServerless: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Demonstrate app lifecycle
|
||||
if err := demonstrateAppLifecycle(ctx, edgeClient, app); err != nil {
|
||||
if err := demonstrateAppLifecycle(ctx, adapter, app); err != nil {
|
||||
log.Fatalf("App lifecycle demonstration failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ SDK example completed successfully!")
|
||||
}
|
||||
|
||||
func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client, input *edgeconnect.NewAppInput) error {
|
||||
func demonstrateAppLifecycle(ctx context.Context, adapter *edgeconnect.Adapter, input *NewAppInput) error {
|
||||
appKey := input.App.Key
|
||||
region := input.Region
|
||||
var domainAppKey domain.AppKey
|
||||
|
||||
fmt.Printf("🚀 Demonstrating EdgeXR SDK with app: %s/%s v%s\n",
|
||||
appKey.Organization, appKey.Name, appKey.Version)
|
||||
|
||||
// Step 1: Create the application
|
||||
fmt.Println("\n1. Creating application...")
|
||||
if err := edgeClient.CreateApp(ctx, input); err != nil {
|
||||
domainApp := &domain.App{
|
||||
Key: domain.AppKey{
|
||||
Organization: input.App.Key.Organization,
|
||||
Name: input.App.Key.Name,
|
||||
Version: input.App.Key.Version,
|
||||
},
|
||||
Deployment: input.App.Deployment,
|
||||
ImageType: input.App.ImageType,
|
||||
ImagePath: input.App.ImagePath,
|
||||
DefaultFlavor: domain.Flavor{Name: input.App.DefaultFlavor.Name},
|
||||
ServerlessConfig: input.App.ServerlessConfig,
|
||||
AllowServerless: input.App.AllowServerless,
|
||||
}
|
||||
if err := adapter.CreateApp(ctx, input.Region, domainApp); err != nil {
|
||||
return fmt.Errorf("failed to create app: %+v", err)
|
||||
}
|
||||
fmt.Printf("✅ App created: %s/%s v%s\n", appKey.Organization, appKey.Name, appKey.Version)
|
||||
fmt.Println(" ✅ App created successfully.")
|
||||
|
||||
// Step 2: Query the application
|
||||
fmt.Println("\n2. Querying application...")
|
||||
app, err := edgeClient.ShowApp(ctx, appKey, region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to show app: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ App found: %s/%s v%s (deployment: %s)\n",
|
||||
app.Key.Organization, app.Key.Name, app.Key.Version, app.Deployment)
|
||||
|
||||
// Step 3: List applications in the organization
|
||||
fmt.Println("\n3. Listing applications...")
|
||||
filter := edgeconnect.AppKey{Organization: appKey.Organization}
|
||||
apps, err := edgeClient.ShowApps(ctx, filter, region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list apps: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ Found %d applications in organization '%s'\n", len(apps), appKey.Organization)
|
||||
|
||||
// Step 4: Clean up - delete the application
|
||||
fmt.Println("\n4. Cleaning up...")
|
||||
if err := edgeClient.DeleteApp(ctx, appKey, region); err != nil {
|
||||
return fmt.Errorf("failed to delete app: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ App deleted: %s/%s v%s\n", appKey.Organization, appKey.Name, appKey.Version)
|
||||
|
||||
// Step 5: Verify deletion
|
||||
fmt.Println("\n5. Verifying deletion...")
|
||||
_, err = edgeClient.ShowApp(ctx, appKey, region)
|
||||
if err != nil {
|
||||
if strings.Contains(fmt.Sprintf("%v", err), edgeconnect.ErrResourceNotFound.Error()) {
|
||||
fmt.Printf("✅ App successfully deleted (not found)\n")
|
||||
} else {
|
||||
return fmt.Errorf("unexpected error verifying deletion: %w", err)
|
||||
// Defer cleanup to ensure the app is deleted even if subsequent steps fail
|
||||
defer func() {
|
||||
fmt.Println("\n4. Cleaning up: Deleting application...")
|
||||
domainAppKey = domain.AppKey{
|
||||
Organization: appKey.Organization,
|
||||
Name: appKey.Name,
|
||||
Version: appKey.Version,
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("app still exists after deletion")
|
||||
if err := adapter.DeleteApp(ctx, region, domainAppKey); err != nil {
|
||||
fmt.Printf(" ⚠️ Cleanup failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(" ✅ App deleted successfully.")
|
||||
}
|
||||
}()
|
||||
|
||||
// Step 2: Verify app creation by fetching it
|
||||
fmt.Println("\n2. Verifying app creation...")
|
||||
domainAppKey = domain.AppKey{
|
||||
Organization: appKey.Organization,
|
||||
Name: appKey.Name,
|
||||
Version: appKey.Version,
|
||||
}
|
||||
fetchedApp, err := adapter.ShowApp(ctx, region, domainAppKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get app after creation: %w", err)
|
||||
}
|
||||
fmt.Printf(" ✅ Fetched app: %s/%s v%s\n",
|
||||
fetchedApp.Key.Organization, fetchedApp.Key.Name, fetchedApp.Key.Version)
|
||||
|
||||
// Step 3: (Placeholder for other operations like updating or deploying)
|
||||
fmt.Println("\n3. Skipping further operations in this example.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper to get environment variables or return a default
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// The structs below are included to make the example self-contained and runnable.
|
||||
// In a real application, these would be defined in the `edgeconnect` package.
|
||||
|
||||
type NewAppInput struct {
|
||||
Region string
|
||||
App App
|
||||
}
|
||||
|
||||
type App struct {
|
||||
Key AppKey
|
||||
Deployment string
|
||||
ImageType string
|
||||
ImagePath string
|
||||
DefaultFlavor Flavor
|
||||
ServerlessConfig interface{}
|
||||
AllowServerless bool
|
||||
}
|
||||
|
||||
type AppKey struct {
|
||||
Organization string
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
type Flavor struct {
|
||||
Name string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue