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