Compare commits
No commits in common. "main" and "feature/parsing_createappinstance" have entirely different histories.
main
...
feature/pa
71 changed files with 412 additions and 22314 deletions
|
|
@ -1,14 +0,0 @@
|
|||
# Example EdgeConnect CLI Configuration File
|
||||
# Place this file at ~/.edge-connect.yaml or specify with --config flag
|
||||
|
||||
# Base URL for the EdgeConnect API
|
||||
base_url: "https://hub.apps.edge.platform.mg3.mdb.osc.live"
|
||||
|
||||
# Authentication credentials
|
||||
username: "your-username@example.com"
|
||||
password: "your-password"
|
||||
|
||||
# API version to use (v1 or v2)
|
||||
# Default: v2
|
||||
# Set via config, --api-version flag, or EDGE_CONNECT_API_VERSION env var
|
||||
api_version: "v2"
|
||||
7
.github/workflows/release.yaml
vendored
7
.github/workflows/release.yaml
vendored
|
|
@ -19,16 +19,9 @@ jobs:
|
|||
go-version: ">=1.25.1"
|
||||
- name: Test code
|
||||
run: make test
|
||||
- name: Import GPG key
|
||||
id: import_gpg
|
||||
uses: https://github.com/crazy-max/ghaction-import-gpg@v6
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
- name: Run GoReleaser
|
||||
uses: https://github.com/goreleaser/goreleaser-action@v6
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
|
||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||
with:
|
||||
args: release --clean
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,5 +5,3 @@ dist/
|
|||
### direnv ###
|
||||
.direnv
|
||||
.envrc
|
||||
|
||||
edge-connect-client
|
||||
|
|
|
|||
|
|
@ -31,18 +31,6 @@ archives:
|
|||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
signs:
|
||||
- artifacts: checksum
|
||||
cmd: gpg
|
||||
args:
|
||||
- "--batch"
|
||||
- "-u"
|
||||
- "{{ .Env.GPG_FINGERPRINT }}"
|
||||
- "--output"
|
||||
- "${signature}"
|
||||
- "--detach-sign"
|
||||
- "${artifact}"
|
||||
|
||||
changelog:
|
||||
abbrev: 10
|
||||
filters:
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -28,7 +28,7 @@ clean:
|
|||
|
||||
# Lint the code
|
||||
lint:
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2 run
|
||||
golangci-lint run
|
||||
|
||||
# Run all checks (generate, test, lint)
|
||||
check: test lint
|
||||
|
|
|
|||
12716
api/swagger_v1.json
12716
api/swagger_v1.json
File diff suppressed because it is too large
Load diff
234
cmd/app.go
234
cmd/app.go
|
|
@ -3,15 +3,12 @@ package cmd
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
|
@ -37,7 +34,7 @@ func validateBaseURL(baseURL string) error {
|
|||
return fmt.Errorf("user and or password should not be set")
|
||||
}
|
||||
|
||||
if url.Path != "" && url.Path != "/" {
|
||||
if !(url.Path == "" || url.Path == "/") {
|
||||
return fmt.Errorf("should not contain any path '%s'", url.Path)
|
||||
}
|
||||
|
||||
|
|
@ -52,15 +49,7 @@ func validateBaseURL(baseURL string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func getAPIVersion() string {
|
||||
version := viper.GetString("api_version")
|
||||
if version == "" {
|
||||
version = "v2" // default to v2
|
||||
}
|
||||
return strings.ToLower(version)
|
||||
}
|
||||
|
||||
func newSDKClientV1() *edgeconnect.Client {
|
||||
func newSDKClient() *edgeconnect.Client {
|
||||
baseURL := viper.GetString("base_url")
|
||||
username := viper.GetString("username")
|
||||
password := viper.GetString("password")
|
||||
|
|
@ -71,53 +60,16 @@ func newSDKClientV1() *edgeconnect.Client {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Build options
|
||||
opts := []edgeconnect.Option{
|
||||
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}),
|
||||
}
|
||||
|
||||
// Add logger only if debug flag is set
|
||||
if debug {
|
||||
logger := log.New(os.Stderr, "[DEBUG] ", log.LstdFlags)
|
||||
opts = append(opts, edgeconnect.WithLogger(logger))
|
||||
}
|
||||
|
||||
if username != "" && password != "" {
|
||||
return edgeconnect.NewClientWithCredentials(baseURL, username, password, opts...)
|
||||
}
|
||||
|
||||
// Fallback to no auth for now - in production should require auth
|
||||
return edgeconnect.NewClient(baseURL, opts...)
|
||||
}
|
||||
|
||||
func newSDKClientV2() *v2.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)
|
||||
}
|
||||
|
||||
// Build options
|
||||
opts := []v2.Option{
|
||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
}
|
||||
|
||||
// Add logger only if debug flag is set
|
||||
if debug {
|
||||
logger := log.New(os.Stderr, "[DEBUG] ", log.LstdFlags)
|
||||
opts = append(opts, v2.WithLogger(logger))
|
||||
}
|
||||
|
||||
if username != "" && password != "" {
|
||||
return v2.NewClientWithCredentials(baseURL, username, password, opts...)
|
||||
}
|
||||
|
||||
// Fallback to no auth for now - in production should require auth
|
||||
return v2.NewClient(baseURL, opts...)
|
||||
)
|
||||
}
|
||||
|
||||
var appCmd = &cobra.Command{
|
||||
|
|
@ -130,37 +82,19 @@ var createAppCmd = &cobra.Command{
|
|||
Use: "create",
|
||||
Short: "Create a new Edge Connect application",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
apiVersion := getAPIVersion()
|
||||
var err error
|
||||
|
||||
if apiVersion == "v1" {
|
||||
c := newSDKClientV1()
|
||||
input := &edgeconnect.NewAppInput{
|
||||
Region: region,
|
||||
App: edgeconnect.App{
|
||||
Key: edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
},
|
||||
c := newSDKClient()
|
||||
input := &edgeconnect.NewAppInput{
|
||||
Region: region,
|
||||
App: edgeconnect.App{
|
||||
Key: edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
},
|
||||
}
|
||||
err = c.CreateApp(context.Background(), input)
|
||||
} else {
|
||||
c := newSDKClientV2()
|
||||
input := &v2.NewAppInput{
|
||||
Region: region,
|
||||
App: v2.App{
|
||||
Key: v2.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
},
|
||||
},
|
||||
}
|
||||
err = c.CreateApp(context.Background(), input)
|
||||
},
|
||||
}
|
||||
|
||||
err := c.CreateApp(context.Background(), input)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating app: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -173,35 +107,19 @@ var showAppCmd = &cobra.Command{
|
|||
Use: "show",
|
||||
Short: "Show details of an Edge Connect application",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
apiVersion := getAPIVersion()
|
||||
|
||||
if apiVersion == "v1" {
|
||||
c := newSDKClientV1()
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
app, err := c.ShowApp(context.Background(), appKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error showing app: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Application details:\n%+v\n", app)
|
||||
} else {
|
||||
c := newSDKClientV2()
|
||||
appKey := v2.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
app, err := c.ShowApp(context.Background(), appKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error showing app: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Application details:\n%+v\n", app)
|
||||
c := newSDKClient()
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
|
||||
app, err := c.ShowApp(context.Background(), appKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error showing app: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Application details:\n%+v\n", app)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -209,40 +127,21 @@ var listAppsCmd = &cobra.Command{
|
|||
Use: "list",
|
||||
Short: "List Edge Connect applications",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
apiVersion := getAPIVersion()
|
||||
c := newSDKClient()
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
|
||||
if apiVersion == "v1" {
|
||||
c := newSDKClientV1()
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
apps, err := c.ShowApps(context.Background(), appKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error listing apps: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Applications:")
|
||||
for _, app := range apps {
|
||||
fmt.Printf("%+v\n", app)
|
||||
}
|
||||
} else {
|
||||
c := newSDKClientV2()
|
||||
appKey := v2.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
apps, err := c.ShowApps(context.Background(), appKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error listing apps: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Applications:")
|
||||
for _, app := range apps {
|
||||
fmt.Printf("%+v\n", app)
|
||||
}
|
||||
apps, err := c.ShowApps(context.Background(), appKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error listing apps: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Applications:")
|
||||
for _, app := range apps {
|
||||
fmt.Printf("%+v\n", app)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -251,27 +150,14 @@ var deleteAppCmd = &cobra.Command{
|
|||
Use: "delete",
|
||||
Short: "Delete an Edge Connect application",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
apiVersion := getAPIVersion()
|
||||
var err error
|
||||
|
||||
if apiVersion == "v1" {
|
||||
c := newSDKClientV1()
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
err = c.DeleteApp(context.Background(), appKey, region)
|
||||
} else {
|
||||
c := newSDKClientV2()
|
||||
appKey := v2.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
err = c.DeleteApp(context.Background(), appKey, region)
|
||||
c := newSDKClient()
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
|
||||
err := c.DeleteApp(context.Background(), appKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error deleting app: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -291,18 +177,12 @@ func init() {
|
|||
cmd.Flags().StringVarP(&appName, "name", "n", "", "application name")
|
||||
cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
|
||||
cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)")
|
||||
if err := cmd.MarkFlagRequired("org"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cmd.MarkFlagRequired("region"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cmd.MarkFlagRequired("org")
|
||||
cmd.MarkFlagRequired("region")
|
||||
}
|
||||
|
||||
// Add required name flag for specific commands
|
||||
for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} {
|
||||
if err := cmd.MarkFlagRequired("name"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cmd.MarkFlagRequired("name")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
154
cmd/apply.go
154
cmd/apply.go
|
|
@ -10,9 +10,8 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
applyv1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/apply/v1"
|
||||
applyv2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/apply/v2"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -31,7 +30,7 @@ the necessary changes to deploy your applications across multiple cloudlets.`,
|
|||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if configFile == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n")
|
||||
_ = cmd.Usage()
|
||||
cmd.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -68,27 +67,16 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error {
|
|||
|
||||
fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name)
|
||||
|
||||
// Step 3: Determine API version and create appropriate client
|
||||
apiVersion := getAPIVersion()
|
||||
// Step 3: Create EdgeConnect client
|
||||
client := newSDKClient()
|
||||
|
||||
// Step 4-6: Execute deployment based on API version
|
||||
if apiVersion == "v1" {
|
||||
return runApplyV1(cfg, manifestContent, isDryRun, autoApprove)
|
||||
}
|
||||
return runApplyV2(cfg, manifestContent, isDryRun, autoApprove)
|
||||
}
|
||||
// Step 4: Create deployment planner
|
||||
planner := apply.NewPlanner(client)
|
||||
|
||||
func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error {
|
||||
// Create v1 client
|
||||
client := newSDKClientV1()
|
||||
|
||||
// Create deployment planner
|
||||
planner := applyv1.NewPlanner(client)
|
||||
|
||||
// Generate deployment plan
|
||||
// Step 5: Generate deployment plan
|
||||
fmt.Println("🔍 Analyzing current state and generating deployment plan...")
|
||||
|
||||
planOptions := applyv1.DefaultPlanOptions()
|
||||
planOptions := apply.DefaultPlanOptions()
|
||||
planOptions.DryRun = isDryRun
|
||||
|
||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
||||
|
|
@ -96,7 +84,7 @@ func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun
|
|||
return fmt.Errorf("failed to generate deployment plan: %w", err)
|
||||
}
|
||||
|
||||
// Display plan summary
|
||||
// Step 6: Display plan summary
|
||||
fmt.Println("\n📋 Deployment Plan:")
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
fmt.Println(result.Plan.Summary)
|
||||
|
|
@ -110,13 +98,13 @@ func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun
|
|||
}
|
||||
}
|
||||
|
||||
// If dry-run, stop here
|
||||
// Step 7: If dry-run, stop here
|
||||
if isDryRun {
|
||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm deployment
|
||||
// Step 8: Confirm deployment (in non-dry-run mode)
|
||||
if result.Plan.TotalActions == 0 {
|
||||
fmt.Println("\n✅ No changes needed. Resources are already in desired state.")
|
||||
return nil
|
||||
|
|
@ -130,98 +118,16 @@ func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun
|
|||
return nil
|
||||
}
|
||||
|
||||
// Execute deployment
|
||||
// Step 9: Execute deployment
|
||||
fmt.Println("\n🚀 Starting deployment...")
|
||||
|
||||
manager := applyv1.NewResourceManager(client, applyv1.WithLogger(log.Default()))
|
||||
manager := apply.NewResourceManager(client, apply.WithLogger(log.Default()))
|
||||
deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deployment failed: %w", err)
|
||||
}
|
||||
|
||||
// Display results
|
||||
return displayDeploymentResults(deployResult)
|
||||
}
|
||||
|
||||
func runApplyV2(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error {
|
||||
// Create v2 client
|
||||
client := newSDKClientV2()
|
||||
|
||||
// Create deployment planner
|
||||
planner := applyv2.NewPlanner(client)
|
||||
|
||||
// Generate deployment plan
|
||||
fmt.Println("🔍 Analyzing current state and generating deployment plan...")
|
||||
|
||||
planOptions := applyv2.DefaultPlanOptions()
|
||||
planOptions.DryRun = isDryRun
|
||||
|
||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate deployment plan: %w", err)
|
||||
}
|
||||
|
||||
// Display plan summary
|
||||
fmt.Println("\n📋 Deployment Plan:")
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
fmt.Println(result.Plan.Summary)
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
|
||||
// Display warnings if any
|
||||
if len(result.Warnings) > 0 {
|
||||
fmt.Println("\n⚠️ Warnings:")
|
||||
for _, warning := range result.Warnings {
|
||||
fmt.Printf(" • %s\n", warning)
|
||||
}
|
||||
}
|
||||
|
||||
// If dry-run, stop here
|
||||
if isDryRun {
|
||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm deployment
|
||||
if result.Plan.TotalActions == 0 {
|
||||
fmt.Println("\n✅ No changes needed. Resources are already in desired state.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\nThis will perform %d actions. Estimated time: %v\n",
|
||||
result.Plan.TotalActions, result.Plan.EstimatedDuration)
|
||||
|
||||
if !autoApprove && !confirmDeployment() {
|
||||
fmt.Println("Deployment cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute deployment
|
||||
fmt.Println("\n🚀 Starting deployment...")
|
||||
|
||||
manager := applyv2.NewResourceManager(client, applyv2.WithLogger(log.Default()))
|
||||
deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deployment failed: %w", err)
|
||||
}
|
||||
|
||||
// Display results
|
||||
return displayDeploymentResults(deployResult)
|
||||
}
|
||||
|
||||
func displayDeploymentResults(result interface{}) error {
|
||||
// Use reflection or type assertion to handle both v1 and v2 result types
|
||||
// For now, we'll use a simple approach that works with both
|
||||
switch r := result.(type) {
|
||||
case *applyv1.ExecutionResult:
|
||||
return displayDeploymentResultsV1(r)
|
||||
case *applyv2.ExecutionResult:
|
||||
return displayDeploymentResultsV2(r)
|
||||
default:
|
||||
return fmt.Errorf("unknown deployment result type")
|
||||
}
|
||||
}
|
||||
|
||||
func displayDeploymentResultsV1(deployResult *applyv1.ExecutionResult) error {
|
||||
// Step 10: Display results
|
||||
if deployResult.Success {
|
||||
fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration)
|
||||
if len(deployResult.CompletedActions) > 0 {
|
||||
|
|
@ -243,38 +149,14 @@ func displayDeploymentResultsV1(deployResult *applyv1.ExecutionResult) error {
|
|||
}
|
||||
return fmt.Errorf("deployment failed with %d failed actions", len(deployResult.FailedActions))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func displayDeploymentResultsV2(deployResult *applyv2.ExecutionResult) error {
|
||||
if deployResult.Success {
|
||||
fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration)
|
||||
if len(deployResult.CompletedActions) > 0 {
|
||||
fmt.Println("\nCompleted actions:")
|
||||
for _, action := range deployResult.CompletedActions {
|
||||
fmt.Printf(" ✅ %s %s\n", action.Type, action.Target)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("\n❌ Deployment failed after %v\n", deployResult.Duration)
|
||||
if deployResult.Error != nil {
|
||||
fmt.Printf("Error: %v\n", deployResult.Error)
|
||||
}
|
||||
if len(deployResult.FailedActions) > 0 {
|
||||
fmt.Println("\nFailed actions:")
|
||||
for _, action := range deployResult.FailedActions {
|
||||
fmt.Printf(" ❌ %s %s: %v\n", action.Type, action.Target, action.Error)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("deployment failed with %d failed actions", len(deployResult.FailedActions))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func confirmDeployment() bool {
|
||||
fmt.Print("Do you want to proceed? (yes/no): ")
|
||||
var response string
|
||||
_, _ = fmt.Scanln(&response)
|
||||
fmt.Scanln(&response)
|
||||
|
||||
switch response {
|
||||
case "yes", "y", "YES", "Y":
|
||||
|
|
@ -291,7 +173,5 @@ func init() {
|
|||
applyCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without applying them")
|
||||
applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan")
|
||||
|
||||
if err := applyCmd.MarkFlagRequired("file"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
applyCmd.MarkFlagRequired("file")
|
||||
}
|
||||
|
|
|
|||
296
cmd/delete.go
296
cmd/delete.go
|
|
@ -1,296 +0,0 @@
|
|||
// ABOUTME: CLI command for deleting EdgeConnect applications from YAML configuration
|
||||
// ABOUTME: Removes applications and their instances based on configuration file specification
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
deletev1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/delete/v1"
|
||||
deletev2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/delete/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
deleteConfigFile string
|
||||
deleteDryRun bool
|
||||
deleteAutoApprove bool
|
||||
)
|
||||
|
||||
var deleteCmd = &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "Delete EdgeConnect applications from configuration files",
|
||||
Long: `Delete EdgeConnect applications and their instances based on YAML configuration files.
|
||||
This command reads a configuration file, finds matching resources, and deletes them.
|
||||
Instances are always deleted before the application.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if deleteConfigFile == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n")
|
||||
_ = cmd.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := runDelete(deleteConfigFile, deleteDryRun, deleteAutoApprove); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func runDelete(configPath string, isDryRun bool, autoApprove bool) error {
|
||||
// Step 1: Validate and resolve config file path
|
||||
absPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve config file path: %w", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("configuration file not found: %s", absPath)
|
||||
}
|
||||
|
||||
fmt.Printf("📄 Loading configuration from: %s\n", absPath)
|
||||
|
||||
// Step 2: Parse and validate configuration
|
||||
parser := config.NewParser()
|
||||
cfg, _, err := parser.ParseFile(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := parser.Validate(cfg); err != nil {
|
||||
return fmt.Errorf("configuration validation failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name)
|
||||
|
||||
// Step 3: Determine API version and create appropriate client
|
||||
apiVersion := getAPIVersion()
|
||||
|
||||
// Step 4: Execute deletion based on API version
|
||||
if apiVersion == "v1" {
|
||||
return runDeleteV1(cfg, isDryRun, autoApprove)
|
||||
}
|
||||
return runDeleteV2(cfg, isDryRun, autoApprove)
|
||||
}
|
||||
|
||||
func runDeleteV1(cfg *config.EdgeConnectConfig, isDryRun bool, autoApprove bool) error {
|
||||
// Create v1 client
|
||||
client := newSDKClientV1()
|
||||
|
||||
// Create deletion planner
|
||||
planner := deletev1.NewPlanner(client)
|
||||
|
||||
// Generate deletion plan
|
||||
fmt.Println("🔍 Analyzing current state and generating deletion plan...")
|
||||
|
||||
planOptions := deletev1.DefaultPlanOptions()
|
||||
planOptions.DryRun = isDryRun
|
||||
|
||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate deletion plan: %w", err)
|
||||
}
|
||||
|
||||
// Display plan summary
|
||||
fmt.Println("\n📋 Deletion Plan:")
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
fmt.Println(result.Plan.Summary)
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
|
||||
// Display warnings if any
|
||||
if len(result.Warnings) > 0 {
|
||||
fmt.Println("\n⚠️ Warnings:")
|
||||
for _, warning := range result.Warnings {
|
||||
fmt.Printf(" • %s\n", warning)
|
||||
}
|
||||
}
|
||||
|
||||
// If dry-run, stop here
|
||||
if isDryRun {
|
||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if there's anything to delete
|
||||
if result.Plan.TotalActions == 0 {
|
||||
fmt.Println("\n✅ No resources found to delete.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\nThis will delete %d resource(s). Estimated time: %v\n",
|
||||
result.Plan.TotalActions, result.Plan.EstimatedDuration)
|
||||
|
||||
if !autoApprove && !confirmDeletion() {
|
||||
fmt.Println("Deletion cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute deletion
|
||||
fmt.Println("\n🗑️ Starting deletion...")
|
||||
|
||||
manager := deletev1.NewResourceManager(client, deletev1.WithLogger(log.Default()))
|
||||
deleteResult, err := manager.ExecuteDeletion(context.Background(), result.Plan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deletion failed: %w", err)
|
||||
}
|
||||
|
||||
// Display results
|
||||
return displayDeletionResults(deleteResult)
|
||||
}
|
||||
|
||||
func runDeleteV2(cfg *config.EdgeConnectConfig, isDryRun bool, autoApprove bool) error {
|
||||
// Create v2 client
|
||||
client := newSDKClientV2()
|
||||
|
||||
// Create deletion planner
|
||||
planner := deletev2.NewPlanner(client)
|
||||
|
||||
// Generate deletion plan
|
||||
fmt.Println("🔍 Analyzing current state and generating deletion plan...")
|
||||
|
||||
planOptions := deletev2.DefaultPlanOptions()
|
||||
planOptions.DryRun = isDryRun
|
||||
|
||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate deletion plan: %w", err)
|
||||
}
|
||||
|
||||
// Display plan summary
|
||||
fmt.Println("\n📋 Deletion Plan:")
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
fmt.Println(result.Plan.Summary)
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
|
||||
// Display warnings if any
|
||||
if len(result.Warnings) > 0 {
|
||||
fmt.Println("\n⚠️ Warnings:")
|
||||
for _, warning := range result.Warnings {
|
||||
fmt.Printf(" • %s\n", warning)
|
||||
}
|
||||
}
|
||||
|
||||
// If dry-run, stop here
|
||||
if isDryRun {
|
||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if there's anything to delete
|
||||
if result.Plan.TotalActions == 0 {
|
||||
fmt.Println("\n✅ No resources found to delete.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\nThis will delete %d resource(s). Estimated time: %v\n",
|
||||
result.Plan.TotalActions, result.Plan.EstimatedDuration)
|
||||
|
||||
if !autoApprove && !confirmDeletion() {
|
||||
fmt.Println("Deletion cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute deletion
|
||||
fmt.Println("\n🗑️ Starting deletion...")
|
||||
|
||||
manager := deletev2.NewResourceManager(client, deletev2.WithLogger(log.Default()))
|
||||
deleteResult, err := manager.ExecuteDeletion(context.Background(), result.Plan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deletion failed: %w", err)
|
||||
}
|
||||
|
||||
// Display results
|
||||
return displayDeletionResults(deleteResult)
|
||||
}
|
||||
|
||||
func displayDeletionResults(result interface{}) error {
|
||||
// Use type assertion to handle both v1 and v2 result types
|
||||
switch r := result.(type) {
|
||||
case *deletev1.DeletionResult:
|
||||
return displayDeletionResultsV1(r)
|
||||
case *deletev2.DeletionResult:
|
||||
return displayDeletionResultsV2(r)
|
||||
default:
|
||||
return fmt.Errorf("unknown deletion result type")
|
||||
}
|
||||
}
|
||||
|
||||
func displayDeletionResultsV1(deleteResult *deletev1.DeletionResult) error {
|
||||
if deleteResult.Success {
|
||||
fmt.Printf("\n✅ Deletion completed successfully in %v\n", deleteResult.Duration)
|
||||
if len(deleteResult.CompletedActions) > 0 {
|
||||
fmt.Println("\nDeleted resources:")
|
||||
for _, action := range deleteResult.CompletedActions {
|
||||
fmt.Printf(" ✅ %s %s\n", action.Type, action.Target)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("\n❌ Deletion failed after %v\n", deleteResult.Duration)
|
||||
if deleteResult.Error != nil {
|
||||
fmt.Printf("Error: %v\n", deleteResult.Error)
|
||||
}
|
||||
if len(deleteResult.FailedActions) > 0 {
|
||||
fmt.Println("\nFailed actions:")
|
||||
for _, action := range deleteResult.FailedActions {
|
||||
fmt.Printf(" ❌ %s %s: %v\n", action.Type, action.Target, action.Error)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("deletion failed with %d failed actions", len(deleteResult.FailedActions))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func displayDeletionResultsV2(deleteResult *deletev2.DeletionResult) error {
|
||||
if deleteResult.Success {
|
||||
fmt.Printf("\n✅ Deletion completed successfully in %v\n", deleteResult.Duration)
|
||||
if len(deleteResult.CompletedActions) > 0 {
|
||||
fmt.Println("\nDeleted resources:")
|
||||
for _, action := range deleteResult.CompletedActions {
|
||||
fmt.Printf(" ✅ %s %s\n", action.Type, action.Target)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("\n❌ Deletion failed after %v\n", deleteResult.Duration)
|
||||
if deleteResult.Error != nil {
|
||||
fmt.Printf("Error: %v\n", deleteResult.Error)
|
||||
}
|
||||
if len(deleteResult.FailedActions) > 0 {
|
||||
fmt.Println("\nFailed actions:")
|
||||
for _, action := range deleteResult.FailedActions {
|
||||
fmt.Printf(" ❌ %s %s: %v\n", action.Type, action.Target, action.Error)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("deletion failed with %d failed actions", len(deleteResult.FailedActions))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func confirmDeletion() bool {
|
||||
fmt.Print("Do you want to proceed with deletion? (yes/no): ")
|
||||
var response string
|
||||
_, _ = fmt.Scanln(&response)
|
||||
|
||||
switch response {
|
||||
case "yes", "y", "YES", "Y":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(deleteCmd)
|
||||
|
||||
deleteCmd.Flags().StringVarP(&deleteConfigFile, "file", "f", "", "configuration file path (required)")
|
||||
deleteCmd.Flags().BoolVar(&deleteDryRun, "dry-run", false, "preview deletion without actually deleting resources")
|
||||
deleteCmd.Flags().BoolVar(&deleteAutoApprove, "auto-approve", false, "automatically approve the deletion plan")
|
||||
|
||||
if err := deleteCmd.MarkFlagRequired("file"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
243
cmd/instance.go
243
cmd/instance.go
|
|
@ -5,8 +5,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -15,7 +14,6 @@ var (
|
|||
cloudletOrg string
|
||||
instanceName string
|
||||
flavorName string
|
||||
appId string
|
||||
)
|
||||
|
||||
var appInstanceCmd = &cobra.Command{
|
||||
|
|
@ -28,59 +26,30 @@ var createInstanceCmd = &cobra.Command{
|
|||
Use: "create",
|
||||
Short: "Create a new Edge Connect application instance",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
apiVersion := getAPIVersion()
|
||||
var err error
|
||||
|
||||
if apiVersion == "v1" {
|
||||
c := newSDKClientV1()
|
||||
input := &edgeconnect.NewAppInstanceInput{
|
||||
Region: region,
|
||||
AppInst: edgeconnect.AppInstance{
|
||||
Key: edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
},
|
||||
AppKey: edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
},
|
||||
Flavor: edgeconnect.Flavor{
|
||||
Name: flavorName,
|
||||
c := newSDKClient()
|
||||
input := &edgeconnect.NewAppInstanceInput{
|
||||
Region: region,
|
||||
AppInst: edgeconnect.AppInstance{
|
||||
Key: edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
},
|
||||
}
|
||||
err = c.CreateAppInstance(context.Background(), input)
|
||||
} else {
|
||||
c := newSDKClientV2()
|
||||
input := &v2.NewAppInstanceInput{
|
||||
Region: region,
|
||||
AppInst: v2.AppInstance{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
},
|
||||
AppKey: v2.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
},
|
||||
Flavor: v2.Flavor{
|
||||
Name: flavorName,
|
||||
},
|
||||
AppKey: edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
},
|
||||
}
|
||||
err = c.CreateAppInstance(context.Background(), input)
|
||||
Flavor: edgeconnect.Flavor{
|
||||
Name: flavorName,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := c.CreateAppInstance(context.Background(), input)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating app instance: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -93,43 +62,22 @@ var showInstanceCmd = &cobra.Command{
|
|||
Use: "show",
|
||||
Short: "Show details of an Edge Connect application instance",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
apiVersion := getAPIVersion()
|
||||
|
||||
if apiVersion == "v1" {
|
||||
c := newSDKClientV1()
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
appkey := edgeconnect.AppKey{Name: appId}
|
||||
instance, err := c.ShowAppInstance(context.Background(), instanceKey, appkey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error showing app instance: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Application instance details:\n%+v\n", instance)
|
||||
} else {
|
||||
c := newSDKClientV2()
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
appkey := v2.AppKey{Name: appId}
|
||||
instance, err := c.ShowAppInstance(context.Background(), instanceKey, appkey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error showing app instance: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Application instance details:\n%+v\n", instance)
|
||||
c := newSDKClient()
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
instance, err := c.ShowAppInstance(context.Background(), instanceKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error showing app instance: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Application instance details:\n%+v\n", instance)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -137,48 +85,24 @@ var listInstancesCmd = &cobra.Command{
|
|||
Use: "list",
|
||||
Short: "List Edge Connect application instances",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
apiVersion := getAPIVersion()
|
||||
c := newSDKClient()
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
if apiVersion == "v1" {
|
||||
c := newSDKClientV1()
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
appKey := edgeconnect.AppKey{Name: appId}
|
||||
instances, err := c.ShowAppInstances(context.Background(), instanceKey, appKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error listing app instances: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Application instances:")
|
||||
for _, instance := range instances {
|
||||
fmt.Printf("%+v\n", instance)
|
||||
}
|
||||
} else {
|
||||
c := newSDKClientV2()
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
appKey := v2.AppKey{Name: appId}
|
||||
instances, err := c.ShowAppInstances(context.Background(), instanceKey, appKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error listing app instances: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Application instances:")
|
||||
for _, instance := range instances {
|
||||
fmt.Printf("%+v\n", instance)
|
||||
}
|
||||
instances, err := c.ShowAppInstances(context.Background(), instanceKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error listing app instances: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Application instances:")
|
||||
for _, instance := range instances {
|
||||
fmt.Printf("%+v\n", instance)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -187,33 +111,17 @@ var deleteInstanceCmd = &cobra.Command{
|
|||
Use: "delete",
|
||||
Short: "Delete an Edge Connect application instance",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
apiVersion := getAPIVersion()
|
||||
var err error
|
||||
|
||||
if apiVersion == "v1" {
|
||||
c := newSDKClientV1()
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
err = c.DeleteAppInstance(context.Background(), instanceKey, region)
|
||||
} else {
|
||||
c := newSDKClientV2()
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
err = c.DeleteAppInstance(context.Background(), instanceKey, region)
|
||||
c := newSDKClient()
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
err := c.DeleteAppInstance(context.Background(), instanceKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error deleting app instance: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -234,33 +142,18 @@ func init() {
|
|||
cmd.Flags().StringVarP(&cloudletName, "cloudlet", "c", "", "cloudlet name (required)")
|
||||
cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)")
|
||||
cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)")
|
||||
cmd.Flags().StringVarP(&appId, "app-id", "i", "", "application id")
|
||||
|
||||
if err := cmd.MarkFlagRequired("org"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cmd.MarkFlagRequired("name"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cmd.MarkFlagRequired("cloudlet"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cmd.MarkFlagRequired("cloudlet-org"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cmd.MarkFlagRequired("region"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cmd.MarkFlagRequired("org")
|
||||
cmd.MarkFlagRequired("name")
|
||||
cmd.MarkFlagRequired("cloudlet")
|
||||
cmd.MarkFlagRequired("cloudlet-org")
|
||||
cmd.MarkFlagRequired("region")
|
||||
}
|
||||
|
||||
// Add additional flags for create command
|
||||
createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)")
|
||||
createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
|
||||
createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)")
|
||||
if err := createInstanceCmd.MarkFlagRequired("app"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := createInstanceCmd.MarkFlagRequired("flavor"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
createInstanceCmd.MarkFlagRequired("app")
|
||||
createInstanceCmd.MarkFlagRequired("flavor")
|
||||
}
|
||||
|
|
|
|||
42
cmd/root.go
42
cmd/root.go
|
|
@ -9,12 +9,10 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
baseURL string
|
||||
username string
|
||||
password string
|
||||
debug bool
|
||||
apiVersion string
|
||||
cfgFile string
|
||||
baseURL string
|
||||
username string
|
||||
password string
|
||||
)
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
|
|
@ -41,38 +39,18 @@ func init() {
|
|||
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")
|
||||
rootCmd.PersistentFlags().StringVar(&apiVersion, "api-version", "v2", "API version to use (v1 or v2)")
|
||||
rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging")
|
||||
|
||||
if err := viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("api_version", rootCmd.PersistentFlags().Lookup("api-version")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
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")
|
||||
if err := viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindEnv("username", "EDGE_CONNECT_USERNAME"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindEnv("password", "EDGE_CONNECT_PASSWORD"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindEnv("api_version", "EDGE_CONNECT_API_VERSION"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
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)
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -1,4 +1,4 @@
|
|||
module edp.buildth.ing/DevFW-CICD/edge-connect-client/v2
|
||||
module edp.buildth.ing/DevFW-CICD/edge-connect-client
|
||||
|
||||
go 1.25.1
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
// ABOUTME: Resource management for EdgeConnect apply command with deployment execution and rollback
|
||||
// ABOUTME: Handles actual deployment operations, manifest processing, and error recovery with parallel execution
|
||||
package v1
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
// ResourceManagerInterface defines the interface for resource management
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios
|
||||
// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients
|
||||
package v1
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -10,8 +10,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
"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"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison
|
||||
// ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls
|
||||
package v1
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -11,8 +11,8 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
// EdgeConnectClientInterface defines the methods needed for deployment planning
|
||||
|
|
@ -21,7 +21,7 @@ type EdgeConnectClientInterface interface {
|
|||
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, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, 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
|
||||
|
|
@ -135,9 +135,9 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E
|
|||
desired := &AppState{
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Metadata.AppVersion,
|
||||
Organization: config.Metadata.Organization, // Use first infra template for org
|
||||
Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region
|
||||
Exists: false, // Will be set based on current state
|
||||
Organization: config.Metadata.Organization, // Use first infra template for org
|
||||
Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region
|
||||
Exists: false, // Will be set based on current state
|
||||
}
|
||||
|
||||
if config.Spec.IsK8sApp() {
|
||||
|
|
@ -323,7 +323,12 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
|
|||
// Extract outbound connections from the app
|
||||
current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections))
|
||||
for i, conn := range app.RequiredOutboundConnections {
|
||||
current.OutboundConnections[i] = SecurityRule(conn)
|
||||
current.OutboundConnections[i] = SecurityRule{
|
||||
Protocol: conn.Protocol,
|
||||
PortRangeMin: conn.PortRangeMin,
|
||||
PortRangeMax: conn.PortRangeMax,
|
||||
RemoteCIDR: conn.RemoteCIDR,
|
||||
}
|
||||
}
|
||||
|
||||
return current, nil
|
||||
|
|
@ -342,11 +347,8 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire
|
|||
Name: desired.CloudletName,
|
||||
},
|
||||
}
|
||||
appKey := edgeconnect.AppKey{
|
||||
Name: desired.AppName,
|
||||
}
|
||||
|
||||
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region)
|
||||
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -390,7 +392,7 @@ func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]str
|
|||
// Compare outbound connections
|
||||
outboundChanges := p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections)
|
||||
if len(outboundChanges) > 0 {
|
||||
sb := strings.Builder{}
|
||||
sb:= strings.Builder{}
|
||||
sb.WriteString("Outbound connections changed:\n")
|
||||
for _, change := range outboundChanges {
|
||||
sb.WriteString(change)
|
||||
|
|
@ -468,9 +470,7 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string,
|
|||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open manifest file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
|
|
@ -505,20 +505,18 @@ func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) ti
|
|||
var duration time.Duration
|
||||
|
||||
// App operations
|
||||
switch plan.AppAction.Type {
|
||||
case ActionCreate:
|
||||
if plan.AppAction.Type == ActionCreate {
|
||||
duration += 30 * time.Second
|
||||
case ActionUpdate:
|
||||
} else if plan.AppAction.Type == ActionUpdate {
|
||||
duration += 15 * time.Second
|
||||
}
|
||||
|
||||
// Instance operations (can be done in parallel)
|
||||
instanceDuration := time.Duration(0)
|
||||
for _, action := range plan.InstanceActions {
|
||||
switch action.Type {
|
||||
case ActionCreate:
|
||||
if action.Type == ActionCreate {
|
||||
instanceDuration = max(instanceDuration, 2*time.Minute)
|
||||
case ActionUpdate:
|
||||
} else if action.Type == ActionUpdate {
|
||||
instanceDuration = max(instanceDuration, 1*time.Minute)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios
|
||||
// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios
|
||||
package v1
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -9,8 +9,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
"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"
|
||||
|
|
@ -29,7 +29,7 @@ func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.
|
|||
return args.Get(0).(edgeconnect.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error) {
|
||||
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)
|
||||
|
|
@ -75,6 +75,14 @@ func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect
|
|||
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)
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
|
||||
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
|
||||
package v1
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
)
|
||||
|
||||
// DeploymentStrategy represents the type of deployment strategy
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect
|
||||
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
|
||||
package v1
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -10,8 +10,8 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
// RecreateStrategy implements the recreate deployment strategy
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
// ABOUTME: Deployment planning types for EdgeConnect apply command with state management
|
||||
// ABOUTME: Defines structures for deployment plans, actions, and state comparison results
|
||||
package v1
|
||||
package apply
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
"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)
|
||||
|
|
@ -1,434 +0,0 @@
|
|||
// ABOUTME: Resource management for EdgeConnect apply command with deployment execution and rollback
|
||||
// ABOUTME: Handles actual deployment operations, manifest processing, and error recovery with parallel execution
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
// ResourceManagerInterface defines the interface for resource management
|
||||
type ResourceManagerInterface interface {
|
||||
// ApplyDeployment executes a deployment plan
|
||||
ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error)
|
||||
|
||||
// RollbackDeployment attempts to rollback a failed deployment
|
||||
RollbackDeployment(ctx context.Context, result *ExecutionResult) error
|
||||
|
||||
// ValidatePrerequisites checks if deployment prerequisites are met
|
||||
ValidatePrerequisites(ctx context.Context, plan *DeploymentPlan) error
|
||||
}
|
||||
|
||||
// EdgeConnectResourceManager implements resource management for EdgeConnect
|
||||
type EdgeConnectResourceManager struct {
|
||||
client EdgeConnectClientInterface
|
||||
parallelLimit int
|
||||
rollbackOnFail bool
|
||||
logger Logger
|
||||
strategyConfig StrategyConfig
|
||||
}
|
||||
|
||||
// Logger interface for deployment logging
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
// ResourceManagerOptions configures the resource manager behavior
|
||||
type ResourceManagerOptions struct {
|
||||
// ParallelLimit controls how many operations run concurrently
|
||||
ParallelLimit int
|
||||
|
||||
// RollbackOnFail automatically rolls back on deployment failure
|
||||
RollbackOnFail bool
|
||||
|
||||
// Logger for deployment operations
|
||||
Logger Logger
|
||||
|
||||
// Timeout for individual operations
|
||||
OperationTimeout time.Duration
|
||||
|
||||
// StrategyConfig for deployment strategies
|
||||
StrategyConfig StrategyConfig
|
||||
}
|
||||
|
||||
// DefaultResourceManagerOptions returns sensible defaults
|
||||
func DefaultResourceManagerOptions() ResourceManagerOptions {
|
||||
return ResourceManagerOptions{
|
||||
ParallelLimit: 5, // Conservative parallel limit
|
||||
RollbackOnFail: true,
|
||||
OperationTimeout: 2 * time.Minute,
|
||||
StrategyConfig: DefaultStrategyConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewResourceManager creates a new EdgeConnect resource manager
|
||||
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
||||
options := DefaultResourceManagerOptions()
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
return &EdgeConnectResourceManager{
|
||||
client: client,
|
||||
parallelLimit: options.ParallelLimit,
|
||||
rollbackOnFail: options.RollbackOnFail,
|
||||
logger: options.Logger,
|
||||
strategyConfig: options.StrategyConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// WithParallelLimit sets the parallel execution limit
|
||||
func WithParallelLimit(limit int) func(*ResourceManagerOptions) {
|
||||
return func(opts *ResourceManagerOptions) {
|
||||
opts.ParallelLimit = limit
|
||||
}
|
||||
}
|
||||
|
||||
// WithRollbackOnFail enables/disables automatic rollback
|
||||
func WithRollbackOnFail(rollback bool) func(*ResourceManagerOptions) {
|
||||
return func(opts *ResourceManagerOptions) {
|
||||
opts.RollbackOnFail = rollback
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets a logger for deployment operations
|
||||
func WithLogger(logger Logger) func(*ResourceManagerOptions) {
|
||||
return func(opts *ResourceManagerOptions) {
|
||||
opts.Logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// WithStrategyConfig sets the strategy configuration
|
||||
func WithStrategyConfig(config StrategyConfig) func(*ResourceManagerOptions) {
|
||||
return func(opts *ResourceManagerOptions) {
|
||||
opts.StrategyConfig = config
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyDeployment executes a deployment plan using deployment strategies
|
||||
func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) {
|
||||
rm.logf("Starting deployment: %s", plan.ConfigName)
|
||||
|
||||
// Step 1: Validate prerequisites
|
||||
if err := rm.ValidatePrerequisites(ctx, plan); err != nil {
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
CompletedActions: []ActionResult{},
|
||||
FailedActions: []ActionResult{},
|
||||
Error: fmt.Errorf("prerequisites validation failed: %w", err),
|
||||
Duration: 0,
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Step 2: Determine deployment strategy
|
||||
strategyName := DeploymentStrategy(config.Spec.GetDeploymentStrategy())
|
||||
rm.logf("Using deployment strategy: %s", strategyName)
|
||||
|
||||
// Step 3: Create strategy executor
|
||||
strategyConfig := rm.strategyConfig
|
||||
strategyConfig.ParallelOperations = rm.parallelLimit > 1
|
||||
|
||||
factory := NewStrategyFactory(rm.client, strategyConfig, rm.logger)
|
||||
strategy, err := factory.CreateStrategy(strategyName)
|
||||
if err != nil {
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
CompletedActions: []ActionResult{},
|
||||
FailedActions: []ActionResult{},
|
||||
Error: fmt.Errorf("failed to create deployment strategy: %w", err),
|
||||
Duration: 0,
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Step 4: Validate strategy can handle this deployment
|
||||
if err := strategy.Validate(plan); err != nil {
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
CompletedActions: []ActionResult{},
|
||||
FailedActions: []ActionResult{},
|
||||
Error: fmt.Errorf("strategy validation failed: %w", err),
|
||||
Duration: 0,
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Step 5: Execute the deployment strategy
|
||||
rm.logf("Estimated deployment duration: %v", strategy.EstimateDuration(plan))
|
||||
result, err := strategy.Execute(ctx, plan, config, manifestContent)
|
||||
|
||||
// Step 6: Handle rollback if needed
|
||||
if err != nil && rm.rollbackOnFail && result != nil {
|
||||
rm.logf("Deployment failed, attempting rollback...")
|
||||
if rollbackErr := rm.RollbackDeployment(ctx, result); rollbackErr != nil {
|
||||
rm.logf("Rollback failed: %v", rollbackErr)
|
||||
} else {
|
||||
result.RollbackPerformed = true
|
||||
result.RollbackSuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil && result.Success {
|
||||
rm.logf("Deployment completed successfully in %v", result.Duration)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ValidatePrerequisites checks if deployment prerequisites are met
|
||||
func (rm *EdgeConnectResourceManager) ValidatePrerequisites(ctx context.Context, plan *DeploymentPlan) error {
|
||||
rm.logf("Validating deployment prerequisites for: %s", plan.ConfigName)
|
||||
|
||||
// Check if we have any actions to perform
|
||||
if plan.IsEmpty() {
|
||||
return fmt.Errorf("deployment plan is empty - no actions to perform")
|
||||
}
|
||||
|
||||
// Validate that we have required client capabilities
|
||||
if rm.client == nil {
|
||||
return fmt.Errorf("EdgeConnect client is not configured")
|
||||
}
|
||||
|
||||
rm.logf("Prerequisites validation passed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackDeployment attempts to rollback a failed deployment
|
||||
func (rm *EdgeConnectResourceManager) RollbackDeployment(ctx context.Context, result *ExecutionResult) error {
|
||||
rm.logf("Starting rollback for deployment: %s", result.Plan.ConfigName)
|
||||
|
||||
rollbackErrors := []error{}
|
||||
|
||||
// Phase 1: Delete resources that were created in this deployment attempt (in reverse order)
|
||||
rm.logf("Phase 1: Rolling back created resources")
|
||||
for i := len(result.CompletedActions) - 1; i >= 0; i-- {
|
||||
action := result.CompletedActions[i]
|
||||
|
||||
switch action.Type {
|
||||
case ActionCreate:
|
||||
if err := rm.rollbackCreateAction(ctx, action, result.Plan); err != nil {
|
||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to rollback %s: %w", action.Target, err))
|
||||
} else {
|
||||
rm.logf("Successfully rolled back: %s", action.Target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Restore resources that were deleted before the failed deployment
|
||||
// This is critical for RecreateStrategy which deletes everything before recreating
|
||||
if result.DeletedAppBackup != nil || len(result.DeletedInstancesBackup) > 0 {
|
||||
rm.logf("Phase 2: Restoring deleted resources")
|
||||
|
||||
// Restore app first (must exist before instances can be created)
|
||||
if result.DeletedAppBackup != nil {
|
||||
if err := rm.restoreApp(ctx, result.DeletedAppBackup); err != nil {
|
||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to restore app: %w", err))
|
||||
rm.logf("Failed to restore app: %v", err)
|
||||
} else {
|
||||
rm.logf("Successfully restored app: %s", result.DeletedAppBackup.App.Key.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore instances
|
||||
for _, backup := range result.DeletedInstancesBackup {
|
||||
if err := rm.restoreInstance(ctx, &backup); err != nil {
|
||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to restore instance %s: %w", backup.Instance.Key.Name, err))
|
||||
rm.logf("Failed to restore instance %s: %v", backup.Instance.Key.Name, err)
|
||||
} else {
|
||||
rm.logf("Successfully restored instance: %s", backup.Instance.Key.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(rollbackErrors) > 0 {
|
||||
return fmt.Errorf("rollback encountered %d errors: %v", len(rollbackErrors), rollbackErrors)
|
||||
}
|
||||
|
||||
rm.logf("Rollback completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollbackCreateAction rolls back a CREATE action by deleting the resource
|
||||
func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
|
||||
if action.Type != ActionCreate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine if this is an app or instance rollback based on the target name
|
||||
isInstance := false
|
||||
for _, instanceAction := range plan.InstanceActions {
|
||||
if instanceAction.InstanceName == action.Target {
|
||||
isInstance = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isInstance {
|
||||
return rm.rollbackInstance(ctx, action, plan)
|
||||
} else {
|
||||
return rm.rollbackApp(ctx, action, plan)
|
||||
}
|
||||
}
|
||||
|
||||
// rollbackApp deletes an application that was created
|
||||
func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
|
||||
appKey := v2.AppKey{
|
||||
Organization: plan.AppAction.Desired.Organization,
|
||||
Name: plan.AppAction.Desired.Name,
|
||||
Version: plan.AppAction.Desired.Version,
|
||||
}
|
||||
|
||||
return rm.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region)
|
||||
}
|
||||
|
||||
// rollbackInstance deletes an instance that was created
|
||||
func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
|
||||
// Find the instance action to get the details
|
||||
for _, instanceAction := range plan.InstanceActions {
|
||||
if instanceAction.InstanceName == action.Target {
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: plan.AppAction.Desired.Organization,
|
||||
Name: instanceAction.InstanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: instanceAction.Target.CloudletOrg,
|
||||
Name: instanceAction.Target.CloudletName,
|
||||
},
|
||||
}
|
||||
return rm.client.DeleteAppInstance(ctx, instanceKey, instanceAction.Target.Region)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("instance action not found for rollback: %s", action.Target)
|
||||
}
|
||||
|
||||
// restoreApp recreates an app that was deleted during deployment
|
||||
func (rm *EdgeConnectResourceManager) restoreApp(ctx context.Context, backup *AppBackup) error {
|
||||
rm.logf("Restoring app: %s/%s version %s",
|
||||
backup.App.Key.Organization, backup.App.Key.Name, backup.App.Key.Version)
|
||||
|
||||
// Build a clean app input with only creation-safe fields
|
||||
// We must exclude read-only fields like CreatedAt, UpdatedAt, etc.
|
||||
appInput := &v2.NewAppInput{
|
||||
Region: backup.Region,
|
||||
App: v2.App{
|
||||
Key: backup.App.Key,
|
||||
Deployment: backup.App.Deployment,
|
||||
ImageType: backup.App.ImageType,
|
||||
ImagePath: backup.App.ImagePath,
|
||||
AllowServerless: backup.App.AllowServerless,
|
||||
DefaultFlavor: backup.App.DefaultFlavor,
|
||||
ServerlessConfig: backup.App.ServerlessConfig,
|
||||
DeploymentManifest: backup.App.DeploymentManifest,
|
||||
DeploymentGenerator: backup.App.DeploymentGenerator,
|
||||
RequiredOutboundConnections: backup.App.RequiredOutboundConnections,
|
||||
// Explicitly omit read-only fields like CreatedAt, UpdatedAt, Fields, etc.
|
||||
},
|
||||
}
|
||||
|
||||
if err := rm.client.CreateApp(ctx, appInput); err != nil {
|
||||
return fmt.Errorf("failed to restore app: %w", err)
|
||||
}
|
||||
|
||||
rm.logf("Successfully restored app: %s", backup.App.Key.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreInstance recreates an instance that was deleted during deployment
|
||||
func (rm *EdgeConnectResourceManager) restoreInstance(ctx context.Context, backup *InstanceBackup) error {
|
||||
rm.logf("Restoring instance: %s on %s:%s",
|
||||
backup.Instance.Key.Name,
|
||||
backup.Instance.Key.CloudletKey.Organization,
|
||||
backup.Instance.Key.CloudletKey.Name)
|
||||
|
||||
// Build a clean instance input with only creation-safe fields
|
||||
// We must exclude read-only fields like CloudletLoc, CreatedAt, etc.
|
||||
instanceInput := &v2.NewAppInstanceInput{
|
||||
Region: backup.Region,
|
||||
AppInst: v2.AppInstance{
|
||||
Key: backup.Instance.Key,
|
||||
AppKey: backup.Instance.AppKey,
|
||||
Flavor: backup.Instance.Flavor,
|
||||
// Explicitly omit read-only fields like CloudletLoc, State, PowerState, CreatedAt, etc.
|
||||
},
|
||||
}
|
||||
|
||||
// Retry logic to handle namespace termination race conditions
|
||||
maxRetries := 5
|
||||
retryDelay := 10 * time.Second
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
rm.logf("Retrying instance restore %s (attempt %d/%d)", backup.Instance.Key.Name, attempt, maxRetries)
|
||||
select {
|
||||
case <-time.After(retryDelay):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err := rm.client.CreateAppInstance(ctx, instanceInput)
|
||||
if err == nil {
|
||||
rm.logf("Successfully restored instance: %s", backup.Instance.Key.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Check if error is retryable
|
||||
if !rm.isRetryableError(err) {
|
||||
rm.logf("Failed to restore instance %s: %v (non-retryable error, giving up)", backup.Instance.Key.Name, err)
|
||||
return fmt.Errorf("failed to restore instance: %w", err)
|
||||
}
|
||||
|
||||
if attempt < maxRetries {
|
||||
rm.logf("Failed to restore instance %s: %v (will retry)", backup.Instance.Key.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to restore instance after %d attempts: %w", maxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// isRetryableError determines if an error should be retried
|
||||
func (rm *EdgeConnectResourceManager) isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Special case: Kubernetes namespace termination race condition
|
||||
// This is a transient 400 error that should be retried
|
||||
if strings.Contains(errStr, "being terminated") || strings.Contains(errStr, "is being terminated") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if it's an APIError with a status code
|
||||
var apiErr *v2.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
// Don't retry client errors (4xx)
|
||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
|
||||
return false
|
||||
}
|
||||
// Retry server errors (5xx)
|
||||
if apiErr.StatusCode >= 500 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Retry all other errors (network issues, timeouts, etc.)
|
||||
return true
|
||||
}
|
||||
|
||||
// logf logs a message if a logger is configured
|
||||
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
|
||||
if rm.logger != nil {
|
||||
rm.logger.Printf("[ResourceManager] "+format, v...)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,603 +0,0 @@
|
|||
// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios
|
||||
// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockResourceClient extends MockEdgeConnectClient with resource management methods
|
||||
type MockResourceClient struct {
|
||||
MockEdgeConnectClient
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) CreateApp(ctx context.Context, input *v2.NewAppInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// TestLogger implements Logger interface for testing
|
||||
type TestLogger struct {
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (l *TestLogger) Printf(format string, v ...interface{}) {
|
||||
l.messages = append(l.messages, fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
func TestNewResourceManager(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
|
||||
assert.NotNil(t, manager)
|
||||
assert.IsType(t, &EdgeConnectResourceManager{}, manager)
|
||||
}
|
||||
|
||||
func TestDefaultResourceManagerOptions(t *testing.T) {
|
||||
opts := DefaultResourceManagerOptions()
|
||||
|
||||
assert.Equal(t, 5, opts.ParallelLimit)
|
||||
assert.True(t, opts.RollbackOnFail)
|
||||
assert.Equal(t, 2*time.Minute, opts.OperationTimeout)
|
||||
}
|
||||
|
||||
func TestWithOptions(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
|
||||
manager := NewResourceManager(mockClient,
|
||||
WithParallelLimit(10),
|
||||
WithRollbackOnFail(false),
|
||||
WithLogger(logger),
|
||||
)
|
||||
|
||||
// Cast to implementation to check options were applied
|
||||
impl := manager.(*EdgeConnectResourceManager)
|
||||
assert.Equal(t, 10, impl.parallelLimit)
|
||||
assert.False(t, impl.rollbackOnFail)
|
||||
assert.Equal(t, logger, impl.logger)
|
||||
}
|
||||
|
||||
func createTestDeploymentPlan() *DeploymentPlan {
|
||||
return &DeploymentPlan{
|
||||
ConfigName: "test-deployment",
|
||||
AppAction: AppAction{
|
||||
Type: ActionCreate,
|
||||
Desired: &AppState{
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
},
|
||||
},
|
||||
InstanceActions: []InstanceAction{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: config.InfraTemplate{
|
||||
Region: "US",
|
||||
CloudletOrg: "cloudletorg",
|
||||
CloudletName: "cloudlet1",
|
||||
FlavorName: "small",
|
||||
},
|
||||
Desired: &InstanceState{
|
||||
Name: "test-app-1.0.0-instance",
|
||||
AppName: "test-app",
|
||||
},
|
||||
InstanceName: "test-app-1.0.0-instance",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestManagerConfig(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: "cloudletorg",
|
||||
CloudletName: "cloudlet1",
|
||||
FlavorName: "small",
|
||||
},
|
||||
},
|
||||
Network: &config.NetworkConfig{
|
||||
OutboundConnections: []config.OutboundConnection{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
PortRangeMax: 80,
|
||||
RemoteCIDR: "0.0.0.0/0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createTestStrategyConfig returns a fast configuration for tests
|
||||
func createTestStrategyConfig() StrategyConfig {
|
||||
return StrategyConfig{
|
||||
MaxRetries: 0, // No retries for fast tests
|
||||
HealthCheckTimeout: 1 * time.Millisecond,
|
||||
ParallelOperations: false, // Sequential for predictable tests
|
||||
RetryDelay: 0, // No delay
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeploymentSuccess(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
// Mock successful operations
|
||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
|
||||
Return(nil)
|
||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 2) // 1 app + 1 instance
|
||||
assert.Len(t, result.FailedActions, 0)
|
||||
assert.False(t, result.RollbackPerformed)
|
||||
assert.Greater(t, result.Duration, time.Duration(0))
|
||||
|
||||
// Check that operations were logged
|
||||
assert.Greater(t, len(logger.messages), 0)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestApplyDeploymentAppFailure(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
// Mock app creation failure - deployment should stop here
|
||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
|
||||
Return(&v2.APIError{StatusCode: 500, Messages: []string{"Server error"}})
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 0)
|
||||
assert.Len(t, result.FailedActions, 1)
|
||||
assert.Contains(t, err.Error(), "Server error")
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
// Mock successful app creation but failed instance creation
|
||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
|
||||
Return(nil)
|
||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
|
||||
Return(&v2.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}})
|
||||
|
||||
// Mock rollback operations
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 1) // App was created
|
||||
assert.Len(t, result.FailedActions, 1) // Instance failed
|
||||
assert.True(t, result.RollbackPerformed)
|
||||
assert.True(t, result.RollbackSuccess)
|
||||
assert.Contains(t, err.Error(), "failed to create instance")
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestApplyDeploymentNoActions(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
|
||||
// Create empty plan
|
||||
plan := &DeploymentPlan{
|
||||
ConfigName: "empty-plan",
|
||||
AppAction: AppAction{Type: ActionNone},
|
||||
}
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Contains(t, err.Error(), "deployment plan is empty")
|
||||
|
||||
mockClient.AssertNotCalled(t, "CreateApp")
|
||||
mockClient.AssertNotCalled(t, "CreateAppInstance")
|
||||
}
|
||||
|
||||
func TestApplyDeploymentMultipleInstances(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
// Create plan with multiple instances
|
||||
plan := &DeploymentPlan{
|
||||
ConfigName: "multi-instance",
|
||||
AppAction: AppAction{
|
||||
Type: ActionCreate,
|
||||
Desired: &AppState{
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
},
|
||||
},
|
||||
InstanceActions: []InstanceAction{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: config.InfraTemplate{
|
||||
Region: "US",
|
||||
CloudletOrg: "cloudletorg1",
|
||||
CloudletName: "cloudlet1",
|
||||
FlavorName: "small",
|
||||
},
|
||||
Desired: &InstanceState{Name: "instance1"},
|
||||
InstanceName: "instance1",
|
||||
},
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: config.InfraTemplate{
|
||||
Region: "EU",
|
||||
CloudletOrg: "cloudletorg2",
|
||||
CloudletName: "cloudlet2",
|
||||
FlavorName: "medium",
|
||||
},
|
||||
Desired: &InstanceState{Name: "instance2"},
|
||||
InstanceName: "instance2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
// Mock successful operations
|
||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
|
||||
Return(nil)
|
||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 3) // 1 app + 2 instances
|
||||
assert.Len(t, result.FailedActions, 0)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestValidatePrerequisites(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
plan *DeploymentPlan
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid plan",
|
||||
plan: &DeploymentPlan{
|
||||
ConfigName: "test",
|
||||
AppAction: AppAction{Type: ActionCreate, Desired: &AppState{}},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty plan",
|
||||
plan: &DeploymentPlan{
|
||||
ConfigName: "test",
|
||||
AppAction: AppAction{Type: ActionNone},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "deployment plan is empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := manager.ValidatePrerequisites(ctx, tt.plan)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackDeployment(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
// Create result with completed actions
|
||||
plan := createTestDeploymentPlan()
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
CompletedActions: []ActionResult{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: "test-app",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: "test-app-1.0.0-instance",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
FailedActions: []ActionResult{},
|
||||
}
|
||||
|
||||
// Mock rollback operations
|
||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(nil)
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
err := manager.RollbackDeployment(ctx, result)
|
||||
|
||||
require.NoError(t, err)
|
||||
mockClient.AssertExpectations(t)
|
||||
|
||||
// Check rollback was logged
|
||||
assert.Greater(t, len(logger.messages), 0)
|
||||
}
|
||||
|
||||
func TestRollbackDeploymentFailure(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
CompletedActions: []ActionResult{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: "test-app",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock rollback failure
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(&v2.APIError{StatusCode: 500, Messages: []string{"Delete failed"}})
|
||||
|
||||
ctx := context.Background()
|
||||
err := manager.RollbackDeployment(ctx, result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "rollback encountered")
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestRollbackDeploymentWithRestore(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
|
||||
// Simulate a RecreateStrategy scenario:
|
||||
// 1. Old app and instance were deleted and backed up
|
||||
// 2. New app was created successfully
|
||||
// 3. New instance creation failed
|
||||
// 4. Rollback should: delete new app, restore old app, restore old instance
|
||||
oldApp := v2.App{
|
||||
Key: v2.AppKey{
|
||||
Organization: "test-org",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Deployment: "kubernetes",
|
||||
DeploymentManifest: "old-manifest-content",
|
||||
}
|
||||
|
||||
oldInstance := v2.AppInstance{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: "test-org",
|
||||
Name: "test-app-1.0.0-instance",
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: "test-cloudlet-org",
|
||||
Name: "test-cloudlet",
|
||||
},
|
||||
},
|
||||
AppKey: v2.AppKey{
|
||||
Organization: "test-org",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Flavor: v2.Flavor{Name: "small"},
|
||||
}
|
||||
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
// Completed actions: new app was created before failure
|
||||
CompletedActions: []ActionResult{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: "test-app",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
// Failed action: new instance creation failed
|
||||
FailedActions: []ActionResult{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: "test-app-1.0.0-instance",
|
||||
Success: false,
|
||||
},
|
||||
},
|
||||
// Backup of deleted resources
|
||||
DeletedAppBackup: &AppBackup{
|
||||
App: oldApp,
|
||||
Region: "US",
|
||||
ManifestContent: "old-manifest-content",
|
||||
},
|
||||
DeletedInstancesBackup: []InstanceBackup{
|
||||
{
|
||||
Instance: oldInstance,
|
||||
Region: "US",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock rollback operations in order:
|
||||
// 1. Delete newly created app (rollback create)
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(nil).Once()
|
||||
|
||||
// 2. Restore old app (from backup)
|
||||
mockClient.On("CreateApp", mock.Anything, mock.MatchedBy(func(input *v2.NewAppInput) bool {
|
||||
return input.App.Key.Name == "test-app" && input.App.DeploymentManifest == "old-manifest-content"
|
||||
})).Return(nil).Once()
|
||||
|
||||
// 3. Restore old instance (from backup)
|
||||
mockClient.On("CreateAppInstance", mock.Anything, mock.MatchedBy(func(input *v2.NewAppInstanceInput) bool {
|
||||
return input.AppInst.Key.Name == "test-app-1.0.0-instance"
|
||||
})).Return(nil).Once()
|
||||
|
||||
ctx := context.Background()
|
||||
err := manager.RollbackDeployment(ctx, result)
|
||||
|
||||
require.NoError(t, err)
|
||||
mockClient.AssertExpectations(t)
|
||||
|
||||
// Verify rollback was logged
|
||||
assert.Greater(t, len(logger.messages), 0)
|
||||
// Should have messages about rolling back created resources and restoring deleted resources
|
||||
hasRestoreLog := false
|
||||
for _, msg := range logger.messages {
|
||||
if strings.Contains(msg, "Restoring deleted resources") {
|
||||
hasRestoreLog = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasRestoreLog, "Should log restoration of deleted resources")
|
||||
}
|
||||
|
||||
func TestConvertNetworkRules(t *testing.T) {
|
||||
network := &config.NetworkConfig{
|
||||
OutboundConnections: []config.OutboundConnection{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
PortRangeMax: 80,
|
||||
RemoteCIDR: "0.0.0.0/0",
|
||||
},
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 443,
|
||||
PortRangeMax: 443,
|
||||
RemoteCIDR: "10.0.0.0/8",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rules := convertNetworkRules(network)
|
||||
require.Len(t, rules, 2)
|
||||
|
||||
assert.Equal(t, "tcp", rules[0].Protocol)
|
||||
assert.Equal(t, 80, rules[0].PortRangeMin)
|
||||
assert.Equal(t, 80, rules[0].PortRangeMax)
|
||||
assert.Equal(t, "0.0.0.0/0", rules[0].RemoteCIDR)
|
||||
|
||||
assert.Equal(t, "tcp", rules[1].Protocol)
|
||||
assert.Equal(t, 443, rules[1].PortRangeMin)
|
||||
assert.Equal(t, 443, rules[1].PortRangeMax)
|
||||
assert.Equal(t, "10.0.0.0/8", rules[1].RemoteCIDR)
|
||||
}
|
||||
|
|
@ -1,556 +0,0 @@
|
|||
// ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison
|
||||
// ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
// EdgeConnectClientInterface defines the methods needed for deployment planning
|
||||
type EdgeConnectClientInterface interface {
|
||||
ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error)
|
||||
CreateApp(ctx context.Context, input *v2.NewAppInput) error
|
||||
UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error
|
||||
DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error
|
||||
ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) (v2.AppInstance, error)
|
||||
CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error
|
||||
UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error
|
||||
DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error
|
||||
}
|
||||
|
||||
// Planner defines the interface for deployment planning
|
||||
type Planner interface {
|
||||
// Plan analyzes the configuration and current state to generate a deployment plan
|
||||
Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error)
|
||||
|
||||
// PlanWithOptions allows customization of planning behavior
|
||||
PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error)
|
||||
}
|
||||
|
||||
// PlanOptions provides configuration for the planning process
|
||||
type PlanOptions struct {
|
||||
// DryRun indicates this is a planning-only operation
|
||||
DryRun bool
|
||||
|
||||
// Force indicates to proceed even with warnings
|
||||
Force bool
|
||||
|
||||
// SkipStateCheck bypasses current state queries (useful for testing)
|
||||
SkipStateCheck bool
|
||||
|
||||
// ParallelQueries enables parallel state fetching
|
||||
ParallelQueries bool
|
||||
|
||||
// Timeout for API operations
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultPlanOptions returns sensible default planning options
|
||||
func DefaultPlanOptions() PlanOptions {
|
||||
return PlanOptions{
|
||||
DryRun: false,
|
||||
Force: false,
|
||||
SkipStateCheck: false,
|
||||
ParallelQueries: true,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
|
||||
type EdgeConnectPlanner struct {
|
||||
client EdgeConnectClientInterface
|
||||
}
|
||||
|
||||
// NewPlanner creates a new EdgeConnect deployment planner
|
||||
func NewPlanner(client EdgeConnectClientInterface) Planner {
|
||||
return &EdgeConnectPlanner{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// Plan analyzes the configuration and generates a deployment plan
|
||||
func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) {
|
||||
return p.PlanWithOptions(ctx, config, DefaultPlanOptions())
|
||||
}
|
||||
|
||||
// PlanWithOptions generates a deployment plan with custom options
|
||||
func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) {
|
||||
startTime := time.Now()
|
||||
var warnings []string
|
||||
|
||||
// Create the deployment plan structure
|
||||
plan := &DeploymentPlan{
|
||||
ConfigName: config.Metadata.Name,
|
||||
CreatedAt: startTime,
|
||||
DryRun: opts.DryRun,
|
||||
}
|
||||
|
||||
// Step 1: Plan application state
|
||||
appAction, appWarnings, err := p.planAppAction(ctx, config, opts)
|
||||
if err != nil {
|
||||
return &PlanResult{Error: err}, err
|
||||
}
|
||||
plan.AppAction = *appAction
|
||||
warnings = append(warnings, appWarnings...)
|
||||
|
||||
// Step 2: Plan instance actions
|
||||
instanceActions, instanceWarnings, err := p.planInstanceActions(ctx, config, opts)
|
||||
if err != nil {
|
||||
return &PlanResult{Error: err}, err
|
||||
}
|
||||
plan.InstanceActions = instanceActions
|
||||
warnings = append(warnings, instanceWarnings...)
|
||||
|
||||
// Step 3: Calculate plan metadata
|
||||
p.calculatePlanMetadata(plan)
|
||||
|
||||
// Step 4: Generate summary
|
||||
plan.Summary = plan.GenerateSummary()
|
||||
|
||||
// Step 5: Validate the plan
|
||||
if err := plan.Validate(); err != nil {
|
||||
return &PlanResult{Error: fmt.Errorf("invalid deployment plan: %w", err)}, err
|
||||
}
|
||||
|
||||
return &PlanResult{
|
||||
Plan: plan,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// planAppAction determines what action needs to be taken for the application
|
||||
func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*AppAction, []string, error) {
|
||||
var warnings []string
|
||||
|
||||
// Build desired app state
|
||||
desired := &AppState{
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Metadata.AppVersion,
|
||||
Organization: config.Metadata.Organization, // Use first infra template for org
|
||||
Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region
|
||||
Exists: false, // Will be set based on current state
|
||||
}
|
||||
|
||||
if config.Spec.IsK8sApp() {
|
||||
desired.AppType = AppTypeK8s
|
||||
} else {
|
||||
desired.AppType = AppTypeDocker
|
||||
}
|
||||
|
||||
// Extract outbound connections from config
|
||||
if config.Spec.Network != nil {
|
||||
desired.OutboundConnections = make([]SecurityRule, len(config.Spec.Network.OutboundConnections))
|
||||
for i, conn := range config.Spec.Network.OutboundConnections {
|
||||
desired.OutboundConnections[i] = SecurityRule{
|
||||
Protocol: conn.Protocol,
|
||||
PortRangeMin: conn.PortRangeMin,
|
||||
PortRangeMax: conn.PortRangeMax,
|
||||
RemoteCIDR: conn.RemoteCIDR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate manifest hash
|
||||
manifestHash, err := p.calculateManifestHash(config.Spec.GetManifestFile())
|
||||
if err != nil {
|
||||
return nil, warnings, fmt.Errorf("failed to calculate manifest hash: %w", err)
|
||||
}
|
||||
desired.ManifestHash = manifestHash
|
||||
|
||||
action := &AppAction{
|
||||
Type: ActionNone,
|
||||
Desired: desired,
|
||||
ManifestHash: manifestHash,
|
||||
Reason: "No action needed",
|
||||
}
|
||||
|
||||
// Skip state check if requested (useful for testing)
|
||||
if opts.SkipStateCheck {
|
||||
action.Type = ActionCreate
|
||||
action.Reason = "Creating app (state check skipped)"
|
||||
action.Changes = []string{"Create new application"}
|
||||
return action, warnings, nil
|
||||
}
|
||||
|
||||
// Query current app state
|
||||
current, err := p.getCurrentAppState(ctx, desired, opts.Timeout)
|
||||
if err != nil {
|
||||
// If app doesn't exist, we need to create it
|
||||
if isResourceNotFoundError(err) {
|
||||
action.Type = ActionCreate
|
||||
action.Reason = "Application does not exist"
|
||||
action.Changes = []string{"Create new application"}
|
||||
return action, warnings, nil
|
||||
}
|
||||
return nil, warnings, fmt.Errorf("failed to query current app state: %w", err)
|
||||
}
|
||||
|
||||
action.Current = current
|
||||
|
||||
// Compare current vs desired state
|
||||
changes, manifestChanged := p.compareAppStates(current, desired)
|
||||
action.ManifestChanged = manifestChanged
|
||||
|
||||
if len(changes) > 0 {
|
||||
action.Type = ActionUpdate
|
||||
action.Changes = changes
|
||||
action.Reason = "Application configuration has changed"
|
||||
fmt.Printf("Changes: %v\n", changes)
|
||||
|
||||
if manifestChanged {
|
||||
warnings = append(warnings, "Manifest file has changed - instances may need to be recreated")
|
||||
}
|
||||
}
|
||||
|
||||
return action, warnings, nil
|
||||
}
|
||||
|
||||
// planInstanceActions determines what actions need to be taken for instances
|
||||
func (p *EdgeConnectPlanner) planInstanceActions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) ([]InstanceAction, []string, error) {
|
||||
var actions []InstanceAction
|
||||
var warnings []string
|
||||
|
||||
for _, infra := range config.Spec.InfraTemplate {
|
||||
instanceName := getInstanceName(config.Metadata.Name, config.Metadata.AppVersion)
|
||||
|
||||
desired := &InstanceState{
|
||||
Name: instanceName,
|
||||
AppVersion: config.Metadata.AppVersion,
|
||||
Organization: config.Metadata.Organization,
|
||||
Region: infra.Region,
|
||||
CloudletOrg: infra.CloudletOrg,
|
||||
CloudletName: infra.CloudletName,
|
||||
FlavorName: infra.FlavorName,
|
||||
Exists: false,
|
||||
}
|
||||
|
||||
action := &InstanceAction{
|
||||
Type: ActionNone,
|
||||
Target: infra,
|
||||
Desired: desired,
|
||||
InstanceName: instanceName,
|
||||
Reason: "No action needed",
|
||||
}
|
||||
|
||||
// Skip state check if requested
|
||||
if opts.SkipStateCheck {
|
||||
action.Type = ActionCreate
|
||||
action.Reason = "Creating instance (state check skipped)"
|
||||
action.Changes = []string{"Create new instance"}
|
||||
actions = append(actions, *action)
|
||||
continue
|
||||
}
|
||||
|
||||
// Query current instance state
|
||||
current, err := p.getCurrentInstanceState(ctx, desired, opts.Timeout)
|
||||
if err != nil {
|
||||
// If instance doesn't exist, we need to create it
|
||||
if isResourceNotFoundError(err) {
|
||||
action.Type = ActionCreate
|
||||
action.Reason = "Instance does not exist"
|
||||
action.Changes = []string{"Create new instance"}
|
||||
actions = append(actions, *action)
|
||||
continue
|
||||
}
|
||||
return nil, warnings, fmt.Errorf("failed to query current instance state: %w", err)
|
||||
}
|
||||
|
||||
action.Current = current
|
||||
|
||||
// Compare current vs desired state
|
||||
changes := p.compareInstanceStates(current, desired)
|
||||
if len(changes) > 0 {
|
||||
action.Type = ActionUpdate
|
||||
action.Changes = changes
|
||||
action.Reason = "Instance configuration has changed"
|
||||
}
|
||||
|
||||
actions = append(actions, *action)
|
||||
}
|
||||
|
||||
return actions, warnings, nil
|
||||
}
|
||||
|
||||
// getCurrentAppState queries the current state of an application
|
||||
func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *AppState, timeout time.Duration) (*AppState, error) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
appKey := v2.AppKey{
|
||||
Organization: desired.Organization,
|
||||
Name: desired.Name,
|
||||
Version: desired.Version,
|
||||
}
|
||||
|
||||
app, err := p.client.ShowApp(timeoutCtx, appKey, desired.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
current := &AppState{
|
||||
Name: app.Key.Name,
|
||||
Version: app.Key.Version,
|
||||
Organization: app.Key.Organization,
|
||||
Region: desired.Region,
|
||||
Exists: true,
|
||||
LastUpdated: time.Now(), // EdgeConnect doesn't provide this, so use current time
|
||||
}
|
||||
|
||||
// Calculate current manifest hash
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(app.DeploymentManifest))
|
||||
current.ManifestHash = fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
|
||||
// Note: EdgeConnect API doesn't currently support annotations for manifest hash tracking
|
||||
// This would be implemented when the API supports it
|
||||
|
||||
// Determine app type based on deployment type
|
||||
if app.Deployment == "kubernetes" {
|
||||
current.AppType = AppTypeK8s
|
||||
} else {
|
||||
current.AppType = AppTypeDocker
|
||||
}
|
||||
|
||||
// Extract outbound connections from the app
|
||||
current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections))
|
||||
for i, conn := range app.RequiredOutboundConnections {
|
||||
current.OutboundConnections[i] = SecurityRule(conn)
|
||||
}
|
||||
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// getCurrentInstanceState queries the current state of an application instance
|
||||
func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desired *InstanceState, timeout time.Duration) (*InstanceState, error) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: desired.Organization,
|
||||
Name: desired.Name,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: desired.CloudletOrg,
|
||||
Name: desired.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
appKey := v2.AppKey{Name: desired.AppName}
|
||||
|
||||
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
current := &InstanceState{
|
||||
Name: instance.Key.Name,
|
||||
AppName: instance.AppKey.Name,
|
||||
AppVersion: instance.AppKey.Version,
|
||||
Organization: instance.Key.Organization,
|
||||
Region: desired.Region,
|
||||
CloudletOrg: instance.Key.CloudletKey.Organization,
|
||||
CloudletName: instance.Key.CloudletKey.Name,
|
||||
FlavorName: instance.Flavor.Name,
|
||||
State: instance.State,
|
||||
PowerState: instance.PowerState,
|
||||
Exists: true,
|
||||
LastUpdated: time.Now(), // EdgeConnect doesn't provide this
|
||||
}
|
||||
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// compareAppStates compares current and desired app states and returns changes
|
||||
func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]string, bool) {
|
||||
var changes []string
|
||||
manifestChanged := false
|
||||
|
||||
// Compare manifest hash - only if both states have hash values
|
||||
// Since EdgeConnect API doesn't support annotations yet, skip manifest hash comparison for now
|
||||
// This would be implemented when the API supports manifest hash tracking
|
||||
if current.ManifestHash != "" && desired.ManifestHash != "" && current.ManifestHash != desired.ManifestHash {
|
||||
changes = append(changes, fmt.Sprintf("Manifest hash changed: %s -> %s", current.ManifestHash, desired.ManifestHash))
|
||||
manifestChanged = true
|
||||
}
|
||||
|
||||
// Compare app type
|
||||
if current.AppType != desired.AppType {
|
||||
changes = append(changes, fmt.Sprintf("App type changed: %s -> %s", current.AppType, desired.AppType))
|
||||
}
|
||||
|
||||
// Compare outbound connections
|
||||
outboundChanges := p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections)
|
||||
if len(outboundChanges) > 0 {
|
||||
sb := strings.Builder{}
|
||||
sb.WriteString("Outbound connections changed:\n")
|
||||
for _, change := range outboundChanges {
|
||||
sb.WriteString(change)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
changes = append(changes, sb.String())
|
||||
}
|
||||
|
||||
return changes, manifestChanged
|
||||
}
|
||||
|
||||
// compareOutboundConnections compares two sets of outbound connections for equality
|
||||
func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) []string {
|
||||
var changes []string
|
||||
makeMap := func(rules []SecurityRule) map[string]SecurityRule {
|
||||
m := make(map[string]SecurityRule, len(rules))
|
||||
for _, r := range rules {
|
||||
key := fmt.Sprintf("%s:%d-%d:%s",
|
||||
strings.ToLower(r.Protocol),
|
||||
r.PortRangeMin,
|
||||
r.PortRangeMax,
|
||||
r.RemoteCIDR,
|
||||
)
|
||||
m[key] = r
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
currentMap := makeMap(current)
|
||||
desiredMap := makeMap(desired)
|
||||
|
||||
// Find added and modified rules
|
||||
for key, rule := range desiredMap {
|
||||
if _, exists := currentMap[key]; !exists {
|
||||
changes = append(changes, fmt.Sprintf(" - Added outbound connection: %s %d-%d to %s", rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteCIDR))
|
||||
}
|
||||
}
|
||||
|
||||
// Find removed rules
|
||||
for key, rule := range currentMap {
|
||||
if _, exists := desiredMap[key]; !exists {
|
||||
changes = append(changes, fmt.Sprintf(" - Removed outbound connection: %s %d-%d to %s", rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteCIDR))
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// compareInstanceStates compares current and desired instance states and returns changes
|
||||
func (p *EdgeConnectPlanner) compareInstanceStates(current, desired *InstanceState) []string {
|
||||
var changes []string
|
||||
|
||||
if current.FlavorName != desired.FlavorName {
|
||||
changes = append(changes, fmt.Sprintf("Flavor changed: %s -> %s", current.FlavorName, desired.FlavorName))
|
||||
}
|
||||
|
||||
if current.CloudletName != desired.CloudletName {
|
||||
changes = append(changes, fmt.Sprintf("Cloudlet changed: %s -> %s", current.CloudletName, desired.CloudletName))
|
||||
}
|
||||
|
||||
if current.CloudletOrg != desired.CloudletOrg {
|
||||
changes = append(changes, fmt.Sprintf("Cloudlet org changed: %s -> %s", current.CloudletOrg, desired.CloudletOrg))
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// calculateManifestHash computes the SHA256 hash of a manifest file
|
||||
func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string, error) {
|
||||
if manifestPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
file, err := os.Open(manifestPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open manifest file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return "", fmt.Errorf("failed to hash manifest file: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// calculatePlanMetadata computes metadata for the deployment plan
|
||||
func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeploymentPlan) {
|
||||
totalActions := 0
|
||||
|
||||
if plan.AppAction.Type != ActionNone {
|
||||
totalActions++
|
||||
}
|
||||
|
||||
for _, action := range plan.InstanceActions {
|
||||
if action.Type != ActionNone {
|
||||
totalActions++
|
||||
}
|
||||
}
|
||||
|
||||
plan.TotalActions = totalActions
|
||||
|
||||
// Estimate duration based on action types and counts
|
||||
plan.EstimatedDuration = p.estimateDeploymentDuration(plan)
|
||||
}
|
||||
|
||||
// estimateDeploymentDuration provides a rough estimate of deployment time
|
||||
func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) time.Duration {
|
||||
var duration time.Duration
|
||||
|
||||
// App operations
|
||||
switch plan.AppAction.Type {
|
||||
case ActionCreate:
|
||||
duration += 30 * time.Second
|
||||
case ActionUpdate:
|
||||
duration += 15 * time.Second
|
||||
}
|
||||
|
||||
// Instance operations (can be done in parallel)
|
||||
instanceDuration := time.Duration(0)
|
||||
for _, action := range plan.InstanceActions {
|
||||
switch action.Type {
|
||||
case ActionCreate:
|
||||
instanceDuration = max(instanceDuration, 2*time.Minute)
|
||||
case ActionUpdate:
|
||||
instanceDuration = max(instanceDuration, 1*time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
duration += instanceDuration
|
||||
|
||||
// Add buffer time
|
||||
duration += 30 * time.Second
|
||||
|
||||
return duration
|
||||
}
|
||||
|
||||
// isResourceNotFoundError checks if an error indicates a resource was not found
|
||||
func isResourceNotFoundError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
return strings.Contains(errStr, "not found") ||
|
||||
strings.Contains(errStr, "does not exist") ||
|
||||
strings.Contains(errStr, "404")
|
||||
}
|
||||
|
||||
// max returns the larger of two durations
|
||||
func max(a, b time.Duration) time.Duration {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// getInstanceName generates the instance name following the pattern: appName-appVersion-instance
|
||||
func getInstanceName(appName, appVersion string) string {
|
||||
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
|
||||
}
|
||||
|
|
@ -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 v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"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 v2.AppKey, region string) (v2.App, error) {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return v2.App{}, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(v2.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) (v2.AppInstance, error) {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return v2.AppInstance{}, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(v2.AppInstance), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *v2.NewAppInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey v2.AppKey, region string) ([]v2.App, error) {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]v2.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]v2.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("v2.AppKey"), "US").
|
||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
||||
|
||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(nil, &v2.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 := &v2.App{
|
||||
Key: v2.AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Deployment: "kubernetes",
|
||||
DeploymentManifest: manifestContent,
|
||||
RequiredOutboundConnections: []v2.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 := &v2.AppInstance{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app-1.0.0-instance",
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: "TestCloudletOrg",
|
||||
Name: "TestCloudlet",
|
||||
},
|
||||
},
|
||||
AppKey: v2.AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Flavor: v2.Flavor{
|
||||
Name: "small",
|
||||
},
|
||||
State: "Ready",
|
||||
PowerState: "PowerOn",
|
||||
}
|
||||
|
||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(*existingApp, nil)
|
||||
|
||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.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("v2.AppKey"), "US").
|
||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
||||
|
||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
|
||||
|
||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "EU").
|
||||
Return(nil, &v2.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", &v2.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true},
|
||||
{"does not exist error", &v2.APIError{Messages: []string{"App does not exist"}}, true},
|
||||
{"404 in message", &v2.APIError{Messages: []string{"HTTP 404 error"}}, true},
|
||||
{"other error", &v2.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("v2.AppKey"), "US").
|
||||
Return(nil, &v2.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,106 +0,0 @@
|
|||
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
|
||||
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
)
|
||||
|
||||
// DeploymentStrategy represents the type of deployment strategy
|
||||
type DeploymentStrategy string
|
||||
|
||||
const (
|
||||
// StrategyRecreate deletes all instances, updates app, then creates new instances
|
||||
StrategyRecreate DeploymentStrategy = "recreate"
|
||||
|
||||
// StrategyBlueGreen creates new instances alongside old ones, then switches traffic (future)
|
||||
StrategyBlueGreen DeploymentStrategy = "blue-green"
|
||||
|
||||
// StrategyRolling updates instances one by one with health checks (future)
|
||||
StrategyRolling DeploymentStrategy = "rolling"
|
||||
)
|
||||
|
||||
// DeploymentStrategyExecutor defines the interface that all deployment strategies must implement
|
||||
type DeploymentStrategyExecutor interface {
|
||||
// Execute runs the deployment strategy
|
||||
Execute(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error)
|
||||
|
||||
// Validate checks if the strategy can be used for this deployment
|
||||
Validate(plan *DeploymentPlan) error
|
||||
|
||||
// EstimateDuration provides time estimate for this strategy
|
||||
EstimateDuration(plan *DeploymentPlan) time.Duration
|
||||
|
||||
// GetName returns the strategy name
|
||||
GetName() DeploymentStrategy
|
||||
}
|
||||
|
||||
// StrategyConfig holds configuration for deployment strategies
|
||||
type StrategyConfig struct {
|
||||
// MaxRetries is the number of times to retry failed operations
|
||||
MaxRetries int
|
||||
|
||||
// HealthCheckTimeout is the maximum time to wait for health checks
|
||||
HealthCheckTimeout time.Duration
|
||||
|
||||
// ParallelOperations enables parallel execution of operations
|
||||
ParallelOperations bool
|
||||
|
||||
// RetryDelay is the delay between retry attempts
|
||||
RetryDelay time.Duration
|
||||
}
|
||||
|
||||
// DefaultStrategyConfig returns sensible defaults for strategy configuration
|
||||
func DefaultStrategyConfig() StrategyConfig {
|
||||
return StrategyConfig{
|
||||
MaxRetries: 5, // Retry 5 times
|
||||
HealthCheckTimeout: 5 * time.Minute, // Max 5 mins health check
|
||||
ParallelOperations: true, // Parallel execution
|
||||
RetryDelay: 10 * time.Second, // 10s between retries
|
||||
}
|
||||
}
|
||||
|
||||
// StrategyFactory creates deployment strategy executors
|
||||
type StrategyFactory struct {
|
||||
config StrategyConfig
|
||||
client EdgeConnectClientInterface
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewStrategyFactory creates a new strategy factory
|
||||
func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *StrategyFactory {
|
||||
return &StrategyFactory{
|
||||
config: config,
|
||||
client: client,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateStrategy creates the appropriate strategy executor based on the deployment strategy
|
||||
func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) {
|
||||
switch strategy {
|
||||
case StrategyRecreate:
|
||||
return NewRecreateStrategy(f.client, f.config, f.logger), nil
|
||||
case StrategyBlueGreen:
|
||||
// TODO: Implement blue-green strategy
|
||||
return nil, fmt.Errorf("blue-green strategy not yet implemented")
|
||||
case StrategyRolling:
|
||||
// TODO: Implement rolling strategy
|
||||
return nil, fmt.Errorf("rolling strategy not yet implemented")
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown deployment strategy: %s", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAvailableStrategies returns a list of all available strategies
|
||||
func (f *StrategyFactory) GetAvailableStrategies() []DeploymentStrategy {
|
||||
return []DeploymentStrategy{
|
||||
StrategyRecreate,
|
||||
// StrategyBlueGreen, // TODO: Enable when implemented
|
||||
// StrategyRolling, // TODO: Enable when implemented
|
||||
}
|
||||
}
|
||||
|
|
@ -1,641 +0,0 @@
|
|||
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect
|
||||
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
// RecreateStrategy implements the recreate deployment strategy
|
||||
type RecreateStrategy struct {
|
||||
client EdgeConnectClientInterface
|
||||
config StrategyConfig
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewRecreateStrategy creates a new recreate strategy executor
|
||||
func NewRecreateStrategy(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *RecreateStrategy {
|
||||
return &RecreateStrategy{
|
||||
client: client,
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetName returns the strategy name
|
||||
func (r *RecreateStrategy) GetName() DeploymentStrategy {
|
||||
return StrategyRecreate
|
||||
}
|
||||
|
||||
// Validate checks if the recreate strategy can be used for this deployment
|
||||
func (r *RecreateStrategy) Validate(plan *DeploymentPlan) error {
|
||||
// Recreate strategy can be used for any deployment
|
||||
// No specific constraints for recreate
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimateDuration estimates the time needed for recreate deployment
|
||||
func (r *RecreateStrategy) EstimateDuration(plan *DeploymentPlan) time.Duration {
|
||||
var duration time.Duration
|
||||
|
||||
// Delete phase - estimate based on number of instances
|
||||
instanceCount := len(plan.InstanceActions)
|
||||
if instanceCount > 0 {
|
||||
deleteTime := time.Duration(instanceCount) * 30 * time.Second
|
||||
if r.config.ParallelOperations {
|
||||
deleteTime = 30 * time.Second // Parallel deletion
|
||||
}
|
||||
duration += deleteTime
|
||||
}
|
||||
|
||||
// App update phase
|
||||
if plan.AppAction.Type == ActionUpdate {
|
||||
duration += 30 * time.Second
|
||||
}
|
||||
|
||||
// Create phase - estimate based on number of instances
|
||||
if instanceCount > 0 {
|
||||
createTime := time.Duration(instanceCount) * 2 * time.Minute
|
||||
if r.config.ParallelOperations {
|
||||
createTime = 2 * time.Minute // Parallel creation
|
||||
}
|
||||
duration += createTime
|
||||
}
|
||||
|
||||
// Health check time
|
||||
duration += r.config.HealthCheckTimeout
|
||||
|
||||
// Add retry buffer (potential retries)
|
||||
retryBuffer := time.Duration(r.config.MaxRetries) * r.config.RetryDelay
|
||||
duration += retryBuffer
|
||||
|
||||
return duration
|
||||
}
|
||||
|
||||
// Execute runs the recreate deployment strategy
|
||||
func (r *RecreateStrategy) Execute(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) {
|
||||
startTime := time.Now()
|
||||
r.logf("Starting recreate deployment strategy for: %s", plan.ConfigName)
|
||||
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
CompletedActions: []ActionResult{},
|
||||
FailedActions: []ActionResult{},
|
||||
}
|
||||
|
||||
// Phase 1: Delete all existing instances
|
||||
if err := r.deleteInstancesPhase(ctx, plan, config, result); err != nil {
|
||||
result.Error = err
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Phase 2: Delete existing app (if updating)
|
||||
if err := r.deleteAppPhase(ctx, plan, config, result); err != nil {
|
||||
result.Error = err
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Phase 3: Create/recreate application
|
||||
if err := r.createAppPhase(ctx, plan, config, manifestContent, result); err != nil {
|
||||
result.Error = err
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Phase 4: Create new instances
|
||||
if err := r.createInstancesPhase(ctx, plan, config, result); err != nil {
|
||||
result.Error = err
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Phase 5: Health check (wait for instances to be ready)
|
||||
if err := r.healthCheckPhase(ctx, plan, result); err != nil {
|
||||
result.Error = err
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.Success = len(result.FailedActions) == 0
|
||||
result.Duration = time.Since(startTime)
|
||||
|
||||
if result.Success {
|
||||
r.logf("Recreate deployment completed successfully in %v", result.Duration)
|
||||
} else {
|
||||
r.logf("Recreate deployment failed with %d failed actions", len(result.FailedActions))
|
||||
}
|
||||
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
// deleteInstancesPhase deletes all existing instances
|
||||
func (r *RecreateStrategy) deleteInstancesPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
|
||||
r.logf("Phase 1: Deleting existing instances")
|
||||
|
||||
// Only delete instances that exist (have ActionUpdate or ActionNone type)
|
||||
instancesToDelete := []InstanceAction{}
|
||||
for _, action := range plan.InstanceActions {
|
||||
if action.Type == ActionUpdate || action.Type == ActionNone {
|
||||
// Convert to delete action
|
||||
deleteAction := action
|
||||
deleteAction.Type = ActionDelete
|
||||
deleteAction.Reason = "Recreate strategy: deleting for recreation"
|
||||
instancesToDelete = append(instancesToDelete, deleteAction)
|
||||
}
|
||||
}
|
||||
|
||||
if len(instancesToDelete) == 0 {
|
||||
r.logf("No existing instances to delete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Backup instances before deleting them (for rollback restoration)
|
||||
r.logf("Backing up %d existing instances before deletion", len(instancesToDelete))
|
||||
for _, action := range instancesToDelete {
|
||||
backup, err := r.backupInstance(ctx, action, config)
|
||||
if err != nil {
|
||||
r.logf("Warning: failed to backup instance %s before deletion: %v", action.InstanceName, err)
|
||||
// Continue with deletion even if backup fails - this is best effort
|
||||
} else {
|
||||
result.DeletedInstancesBackup = append(result.DeletedInstancesBackup, *backup)
|
||||
r.logf("Backed up instance: %s", action.InstanceName)
|
||||
}
|
||||
}
|
||||
|
||||
deleteResults := r.executeInstanceActionsWithRetry(ctx, instancesToDelete, "delete", config)
|
||||
|
||||
for _, deleteResult := range deleteResults {
|
||||
if deleteResult.Success {
|
||||
result.CompletedActions = append(result.CompletedActions, deleteResult)
|
||||
r.logf("Deleted instance: %s", deleteResult.Target)
|
||||
} else {
|
||||
result.FailedActions = append(result.FailedActions, deleteResult)
|
||||
return fmt.Errorf("failed to delete instance %s: %w", deleteResult.Target, deleteResult.Error)
|
||||
}
|
||||
}
|
||||
|
||||
r.logf("Phase 1 complete: deleted %d instances", len(deleteResults))
|
||||
|
||||
// Wait for Kubernetes namespace termination to complete
|
||||
// This prevents "namespace is being terminated" errors when recreating instances
|
||||
if len(deleteResults) > 0 {
|
||||
waitTime := 5 * time.Second
|
||||
r.logf("Waiting %v for namespace termination to complete...", waitTime)
|
||||
select {
|
||||
case <-time.After(waitTime):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteAppPhase deletes the existing app (if updating)
|
||||
func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
|
||||
if plan.AppAction.Type != ActionUpdate {
|
||||
r.logf("Phase 2: No app deletion needed (new app)")
|
||||
return nil
|
||||
}
|
||||
|
||||
r.logf("Phase 2: Deleting existing application")
|
||||
|
||||
// Backup app before deleting it (for rollback restoration)
|
||||
r.logf("Backing up existing app before deletion")
|
||||
backup, err := r.backupApp(ctx, plan, config)
|
||||
if err != nil {
|
||||
r.logf("Warning: failed to backup app before deletion: %v", err)
|
||||
// Continue with deletion even if backup fails - this is best effort
|
||||
} else {
|
||||
result.DeletedAppBackup = backup
|
||||
r.logf("Backed up app: %s", plan.AppAction.Desired.Name)
|
||||
}
|
||||
|
||||
appKey := v2.AppKey{
|
||||
Organization: plan.AppAction.Desired.Organization,
|
||||
Name: plan.AppAction.Desired.Name,
|
||||
Version: plan.AppAction.Desired.Version,
|
||||
}
|
||||
|
||||
if err := r.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region); err != nil {
|
||||
result.FailedActions = append(result.FailedActions, ActionResult{
|
||||
Type: ActionDelete,
|
||||
Target: plan.AppAction.Desired.Name,
|
||||
Success: false,
|
||||
Error: err,
|
||||
})
|
||||
return fmt.Errorf("failed to delete app: %w", err)
|
||||
}
|
||||
|
||||
result.CompletedActions = append(result.CompletedActions, ActionResult{
|
||||
Type: ActionDelete,
|
||||
Target: plan.AppAction.Desired.Name,
|
||||
Success: true,
|
||||
Details: fmt.Sprintf("Deleted app %s", plan.AppAction.Desired.Name),
|
||||
})
|
||||
|
||||
r.logf("Phase 2 complete: deleted existing application")
|
||||
return nil
|
||||
}
|
||||
|
||||
// createAppPhase creates the application (always create since we deleted it first)
|
||||
func (r *RecreateStrategy) createAppPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string, result *ExecutionResult) error {
|
||||
if plan.AppAction.Type == ActionNone {
|
||||
r.logf("Phase 3: No app creation needed")
|
||||
return nil
|
||||
}
|
||||
|
||||
r.logf("Phase 3: Creating application")
|
||||
|
||||
// Always use create since recreate strategy deletes first
|
||||
createAction := plan.AppAction
|
||||
createAction.Type = ActionCreate
|
||||
createAction.Reason = "Recreate strategy: creating app"
|
||||
|
||||
appResult := r.executeAppActionWithRetry(ctx, createAction, config, manifestContent)
|
||||
|
||||
if appResult.Success {
|
||||
result.CompletedActions = append(result.CompletedActions, appResult)
|
||||
r.logf("Phase 3 complete: app created successfully")
|
||||
return nil
|
||||
} else {
|
||||
result.FailedActions = append(result.FailedActions, appResult)
|
||||
return fmt.Errorf("failed to create app: %w", appResult.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// createInstancesPhase creates new instances
|
||||
func (r *RecreateStrategy) createInstancesPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
|
||||
r.logf("Phase 4: Creating new instances")
|
||||
|
||||
// Convert all instance actions to create
|
||||
instancesToCreate := []InstanceAction{}
|
||||
for _, action := range plan.InstanceActions {
|
||||
createAction := action
|
||||
createAction.Type = ActionCreate
|
||||
createAction.Reason = "Recreate strategy: creating new instance"
|
||||
instancesToCreate = append(instancesToCreate, createAction)
|
||||
}
|
||||
|
||||
if len(instancesToCreate) == 0 {
|
||||
r.logf("No instances to create")
|
||||
return nil
|
||||
}
|
||||
|
||||
createResults := r.executeInstanceActionsWithRetry(ctx, instancesToCreate, "create", config)
|
||||
|
||||
for _, createResult := range createResults {
|
||||
if createResult.Success {
|
||||
result.CompletedActions = append(result.CompletedActions, createResult)
|
||||
r.logf("Created instance: %s", createResult.Target)
|
||||
} else {
|
||||
result.FailedActions = append(result.FailedActions, createResult)
|
||||
return fmt.Errorf("failed to create instance %s: %w", createResult.Target, createResult.Error)
|
||||
}
|
||||
}
|
||||
|
||||
r.logf("Phase 4 complete: created %d instances", len(createResults))
|
||||
return nil
|
||||
}
|
||||
|
||||
// healthCheckPhase waits for instances to become ready
|
||||
func (r *RecreateStrategy) healthCheckPhase(ctx context.Context, plan *DeploymentPlan, result *ExecutionResult) error {
|
||||
if len(plan.InstanceActions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.logf("Phase 5: Performing health checks")
|
||||
|
||||
// TODO: Implement actual health checks by querying instance status
|
||||
// For now, skip waiting in tests/mock environments
|
||||
r.logf("Phase 5 complete: health check passed (no wait)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeInstanceActionsWithRetry executes instance actions with retry logic
|
||||
func (r *RecreateStrategy) executeInstanceActionsWithRetry(ctx context.Context, actions []InstanceAction, operation string, config *config.EdgeConnectConfig) []ActionResult {
|
||||
results := make([]ActionResult, len(actions))
|
||||
|
||||
if r.config.ParallelOperations && len(actions) > 1 {
|
||||
// Parallel execution
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 5) // Limit concurrency
|
||||
|
||||
for i, action := range actions {
|
||||
wg.Add(1)
|
||||
go func(index int, instanceAction InstanceAction) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
results[index] = r.executeInstanceActionWithRetry(ctx, instanceAction, operation, config)
|
||||
}(i, action)
|
||||
}
|
||||
wg.Wait()
|
||||
} else {
|
||||
// Sequential execution
|
||||
for i, action := range actions {
|
||||
results[i] = r.executeInstanceActionWithRetry(ctx, action, operation, config)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// executeInstanceActionWithRetry executes a single instance action with retry logic
|
||||
func (r *RecreateStrategy) executeInstanceActionWithRetry(ctx context.Context, action InstanceAction, operation string, config *config.EdgeConnectConfig) ActionResult {
|
||||
startTime := time.Now()
|
||||
result := ActionResult{
|
||||
Type: action.Type,
|
||||
Target: action.InstanceName,
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
r.logf("Retrying %s for instance %s (attempt %d/%d)", operation, action.InstanceName, attempt, r.config.MaxRetries)
|
||||
select {
|
||||
case <-time.After(r.config.RetryDelay):
|
||||
case <-ctx.Done():
|
||||
result.Error = ctx.Err()
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
var success bool
|
||||
var err error
|
||||
|
||||
switch action.Type {
|
||||
case ActionDelete:
|
||||
success, err = r.deleteInstance(ctx, action)
|
||||
case ActionCreate:
|
||||
success, err = r.createInstance(ctx, action, config)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported action type: %s", action.Type)
|
||||
}
|
||||
|
||||
if success {
|
||||
result.Success = true
|
||||
result.Details = fmt.Sprintf("Successfully %sd instance %s", strings.ToLower(string(action.Type)), action.InstanceName)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Check if error is retryable (don't retry 4xx client errors)
|
||||
if !isRetryableError(err) {
|
||||
r.logf("Failed to %s instance %s: %v (non-retryable error, giving up)", operation, action.InstanceName, err)
|
||||
result.Error = fmt.Errorf("non-retryable error: %w", err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
if attempt < r.config.MaxRetries {
|
||||
r.logf("Failed to %s instance %s: %v (will retry)", operation, action.InstanceName, err)
|
||||
}
|
||||
}
|
||||
|
||||
result.Error = fmt.Errorf("failed after %d attempts: %w", r.config.MaxRetries+1, lastErr)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
// executeAppActionWithRetry executes app action with retry logic
|
||||
func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) ActionResult {
|
||||
startTime := time.Now()
|
||||
result := ActionResult{
|
||||
Type: action.Type,
|
||||
Target: action.Desired.Name,
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
r.logf("Retrying app update (attempt %d/%d)", attempt, r.config.MaxRetries)
|
||||
select {
|
||||
case <-time.After(r.config.RetryDelay):
|
||||
case <-ctx.Done():
|
||||
result.Error = ctx.Err()
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
success, err := r.updateApplication(ctx, action, config, manifestContent)
|
||||
if success {
|
||||
result.Success = true
|
||||
result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Check if error is retryable (don't retry 4xx client errors)
|
||||
if !isRetryableError(err) {
|
||||
r.logf("Failed to update app: %v (non-retryable error, giving up)", err)
|
||||
result.Error = fmt.Errorf("non-retryable error: %w", err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
if attempt < r.config.MaxRetries {
|
||||
r.logf("Failed to update app: %v (will retry)", err)
|
||||
}
|
||||
}
|
||||
|
||||
result.Error = fmt.Errorf("failed after %d attempts: %w", r.config.MaxRetries+1, lastErr)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
// deleteInstance deletes an instance (reuse existing logic from manager.go)
|
||||
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.InstanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: action.Target.CloudletOrg,
|
||||
Name: action.Target.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
err := r.client.DeleteAppInstance(ctx, instanceKey, action.Target.Region)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to delete instance: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// createInstance creates an instance (extracted from manager.go logic)
|
||||
func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
|
||||
instanceInput := &v2.NewAppInstanceInput{
|
||||
Region: action.Target.Region,
|
||||
AppInst: v2.AppInstance{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.InstanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: action.Target.CloudletOrg,
|
||||
Name: action.Target.CloudletName,
|
||||
},
|
||||
},
|
||||
AppKey: v2.AppKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Metadata.AppVersion,
|
||||
},
|
||||
Flavor: v2.Flavor{
|
||||
Name: action.Target.FlavorName,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create the instance
|
||||
if err := r.client.CreateAppInstance(ctx, instanceInput); err != nil {
|
||||
return false, fmt.Errorf("failed to create instance: %w", err)
|
||||
}
|
||||
|
||||
r.logf("Successfully created instance: %s on %s:%s",
|
||||
action.InstanceName, action.Target.CloudletOrg, action.Target.CloudletName)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// updateApplication creates/recreates an application (always uses CreateApp since we delete first)
|
||||
func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) {
|
||||
// Build the app create input - always create since recreate strategy deletes first
|
||||
appInput := &v2.NewAppInput{
|
||||
Region: action.Desired.Region,
|
||||
App: v2.App{
|
||||
Key: v2.AppKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.Desired.Name,
|
||||
Version: action.Desired.Version,
|
||||
},
|
||||
Deployment: config.GetDeploymentType(),
|
||||
ImageType: "ImageTypeDocker",
|
||||
ImagePath: config.GetImagePath(),
|
||||
AllowServerless: true,
|
||||
DefaultFlavor: v2.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
|
||||
ServerlessConfig: struct{}{},
|
||||
DeploymentManifest: manifestContent,
|
||||
DeploymentGenerator: "kubernetes-basic",
|
||||
},
|
||||
}
|
||||
|
||||
// Add network configuration if specified
|
||||
if config.Spec.Network != nil {
|
||||
appInput.App.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network)
|
||||
}
|
||||
|
||||
// Create the application (recreate strategy always creates from scratch)
|
||||
if err := r.client.CreateApp(ctx, appInput); err != nil {
|
||||
return false, fmt.Errorf("failed to create application: %w", err)
|
||||
}
|
||||
|
||||
r.logf("Successfully created application: %s/%s version %s",
|
||||
action.Desired.Organization, action.Desired.Name, action.Desired.Version)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// backupApp fetches and stores the current app state before deletion
|
||||
func (r *RecreateStrategy) backupApp(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig) (*AppBackup, error) {
|
||||
appKey := v2.AppKey{
|
||||
Organization: plan.AppAction.Desired.Organization,
|
||||
Name: plan.AppAction.Desired.Name,
|
||||
Version: plan.AppAction.Desired.Version,
|
||||
}
|
||||
|
||||
app, err := r.client.ShowApp(ctx, appKey, plan.AppAction.Desired.Region)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch app for backup: %w", err)
|
||||
}
|
||||
|
||||
backup := &AppBackup{
|
||||
App: app,
|
||||
Region: plan.AppAction.Desired.Region,
|
||||
ManifestContent: app.DeploymentManifest,
|
||||
}
|
||||
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
// backupInstance fetches and stores the current instance state before deletion
|
||||
func (r *RecreateStrategy) backupInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (*InstanceBackup, error) {
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.InstanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: action.Target.CloudletOrg,
|
||||
Name: action.Target.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
appKey := v2.AppKey{Name: action.Desired.AppName}
|
||||
|
||||
instance, err := r.client.ShowAppInstance(ctx, instanceKey, appKey, action.Target.Region)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch instance for backup: %w", err)
|
||||
}
|
||||
|
||||
backup := &InstanceBackup{
|
||||
Instance: instance,
|
||||
Region: action.Target.Region,
|
||||
}
|
||||
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
// logf logs a message if a logger is configured
|
||||
func (r *RecreateStrategy) logf(format string, v ...interface{}) {
|
||||
if r.logger != nil {
|
||||
r.logger.Printf("[RecreateStrategy] "+format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
// isRetryableError determines if an error should be retried
|
||||
// Returns false for client errors (4xx), true for server errors (5xx) and other transient errors
|
||||
func isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Special case: Kubernetes namespace termination race condition
|
||||
// This is a transient 400 error that should be retried
|
||||
if strings.Contains(errStr, "being terminated") || strings.Contains(errStr, "is being terminated") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if it's an APIError with a status code
|
||||
var apiErr *v2.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
// Don't retry client errors (4xx)
|
||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
|
||||
return false
|
||||
}
|
||||
// Retry server errors (5xx)
|
||||
if apiErr.StatusCode >= 500 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Retry all other errors (network issues, timeouts, etc.)
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,489 +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 v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
// SecurityRule defines network access rules (alias to SDK type for consistency)
|
||||
type SecurityRule = v2.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
|
||||
|
||||
// DeletedAppBackup stores the app that was deleted (for rollback restoration)
|
||||
DeletedAppBackup *AppBackup
|
||||
|
||||
// DeletedInstancesBackup stores instances that were deleted (for rollback restoration)
|
||||
DeletedInstancesBackup []InstanceBackup
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// AppBackup stores a deleted app's complete state for rollback restoration
|
||||
type AppBackup struct {
|
||||
// App is the full app object that was deleted
|
||||
App v2.App
|
||||
|
||||
// Region where the app was deployed
|
||||
Region string
|
||||
|
||||
// ManifestContent is the deployment manifest content
|
||||
ManifestContent string
|
||||
}
|
||||
|
||||
// InstanceBackup stores a deleted instance's complete state for rollback restoration
|
||||
type InstanceBackup struct {
|
||||
// Instance is the full instance object that was deleted
|
||||
Instance v2.AppInstance
|
||||
|
||||
// Region where the instance was deployed
|
||||
Region 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) []v2.SecurityRule {
|
||||
rules := make([]v2.SecurityRule, len(network.OutboundConnections))
|
||||
|
||||
for i, conn := range network.OutboundConnections {
|
||||
rules[i] = v2.SecurityRule{
|
||||
Protocol: conn.Protocol,
|
||||
PortRangeMin: conn.PortRangeMin,
|
||||
PortRangeMax: conn.PortRangeMax,
|
||||
RemoteCIDR: conn.RemoteCIDR,
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ func TestParseExampleConfig(t *testing.T) {
|
|||
parser := NewParser()
|
||||
|
||||
// Parse the actual example file (now that we've created the manifest file)
|
||||
examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig_v1.yaml")
|
||||
examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig.yaml")
|
||||
config, parsedManifest, err := parser.ParseFile(examplePath)
|
||||
|
||||
// This should now succeed with full validation
|
||||
|
|
@ -70,13 +70,13 @@ func TestValidateExampleStructure(t *testing.T) {
|
|||
config := &EdgeConnectConfig{
|
||||
Kind: "edgeconnect-deployment",
|
||||
Metadata: Metadata{
|
||||
Name: "edge-app-demo",
|
||||
AppVersion: "1.0.0",
|
||||
Name: "edge-app-demo",
|
||||
AppVersion: "1.0.0",
|
||||
Organization: "edp2",
|
||||
},
|
||||
Spec: Spec{
|
||||
DockerApp: &DockerApp{ // Use DockerApp to avoid manifest file validation
|
||||
Image: "nginx:latest",
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
InfraTemplate: []InfraTemplate{
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// EdgeConnectConfig represents the top-level configuration structure
|
||||
|
|
@ -100,75 +98,10 @@ func (c *EdgeConnectConfig) GetImagePath() string {
|
|||
if c.Spec.IsDockerApp() && c.Spec.DockerApp.Image != "" {
|
||||
return c.Spec.DockerApp.Image
|
||||
}
|
||||
|
||||
// For kubernetes apps, extract image from manifest
|
||||
if c.Spec.IsK8sApp() && c.Spec.K8sApp.ManifestFile != "" {
|
||||
if image, err := extractImageFromK8sManifest(c.Spec.K8sApp.ManifestFile); err == nil && image != "" {
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback default for kubernetes apps
|
||||
// Default for kubernetes apps
|
||||
return "https://registry-1.docker.io/library/nginx:latest"
|
||||
}
|
||||
|
||||
// extractImageFromK8sManifest extracts the container image from a Kubernetes manifest
|
||||
func extractImageFromK8sManifest(manifestPath string) (string, error) {
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read manifest: %w", err)
|
||||
}
|
||||
|
||||
// Parse multi-document YAML
|
||||
decoder := yaml.NewDecoder(strings.NewReader(string(data)))
|
||||
|
||||
for {
|
||||
var doc map[string]interface{}
|
||||
if err := decoder.Decode(&doc); err != nil {
|
||||
break // End of documents or error
|
||||
}
|
||||
|
||||
// Check if this is a Deployment
|
||||
kind, ok := doc["kind"].(string)
|
||||
if !ok || kind != "Deployment" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Navigate to spec.template.spec.containers[0].image
|
||||
spec, ok := doc["spec"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
template, ok := spec["template"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
templateSpec, ok := template["spec"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
containers, ok := templateSpec["containers"].([]interface{})
|
||||
if !ok || len(containers) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
firstContainer, ok := containers[0].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
image, ok := firstContainer["image"].(string)
|
||||
if ok && image != "" {
|
||||
return image, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no image found in Deployment manifest")
|
||||
}
|
||||
|
||||
// Validate validates metadata fields
|
||||
func (m *Metadata) Validate() error {
|
||||
if m.Name == "" {
|
||||
|
|
|
|||
|
|
@ -1,166 +0,0 @@
|
|||
// ABOUTME: Resource management for EdgeConnect delete command with deletion execution
|
||||
// ABOUTME: Handles actual deletion operations with proper ordering (instances first, then app)
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
// ResourceManagerInterface defines the interface for resource management
|
||||
type ResourceManagerInterface interface {
|
||||
// ExecuteDeletion executes a deletion plan
|
||||
ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error)
|
||||
}
|
||||
|
||||
// EdgeConnectResourceManager implements resource management for EdgeConnect
|
||||
type EdgeConnectResourceManager struct {
|
||||
client EdgeConnectClientInterface
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// Logger interface for deletion logging
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
// ResourceManagerOptions configures the resource manager behavior
|
||||
type ResourceManagerOptions struct {
|
||||
// Logger for deletion operations
|
||||
Logger Logger
|
||||
}
|
||||
|
||||
// DefaultResourceManagerOptions returns sensible defaults
|
||||
func DefaultResourceManagerOptions() ResourceManagerOptions {
|
||||
return ResourceManagerOptions{
|
||||
Logger: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// NewResourceManager creates a new EdgeConnect resource manager
|
||||
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
||||
options := DefaultResourceManagerOptions()
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
return &EdgeConnectResourceManager{
|
||||
client: client,
|
||||
logger: options.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets a logger for deletion operations
|
||||
func WithLogger(logger Logger) func(*ResourceManagerOptions) {
|
||||
return func(opts *ResourceManagerOptions) {
|
||||
opts.Logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteDeletion executes a deletion plan
|
||||
// Important: Instances must be deleted before the app
|
||||
func (rm *EdgeConnectResourceManager) ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error) {
|
||||
startTime := time.Now()
|
||||
rm.logf("Starting deletion: %s", plan.ConfigName)
|
||||
|
||||
result := &DeletionResult{
|
||||
Plan: plan,
|
||||
Success: true,
|
||||
CompletedActions: []DeletionActionResult{},
|
||||
FailedActions: []DeletionActionResult{},
|
||||
}
|
||||
|
||||
// If plan is empty, return success immediately
|
||||
if plan.IsEmpty() {
|
||||
rm.logf("No resources to delete")
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Step 1: Delete all instances first
|
||||
for _, instance := range plan.InstancesToDelete {
|
||||
actionStart := time.Now()
|
||||
rm.logf("Deleting instance: %s", instance.Name)
|
||||
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: instance.Organization,
|
||||
Name: instance.Name,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: instance.CloudletOrg,
|
||||
Name: instance.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
err := rm.client.DeleteAppInstance(ctx, instanceKey, instance.Region)
|
||||
actionResult := DeletionActionResult{
|
||||
Type: "instance",
|
||||
Target: instance.Name,
|
||||
Duration: time.Since(actionStart),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
rm.logf("Failed to delete instance %s: %v", instance.Name, err)
|
||||
actionResult.Success = false
|
||||
actionResult.Error = err
|
||||
result.FailedActions = append(result.FailedActions, actionResult)
|
||||
result.Success = false
|
||||
result.Error = fmt.Errorf("failed to delete instance %s: %w", instance.Name, err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
rm.logf("Successfully deleted instance: %s", instance.Name)
|
||||
actionResult.Success = true
|
||||
result.CompletedActions = append(result.CompletedActions, actionResult)
|
||||
}
|
||||
|
||||
// Step 2: Delete the app (only after all instances are deleted)
|
||||
if plan.AppToDelete != nil {
|
||||
actionStart := time.Now()
|
||||
app := plan.AppToDelete
|
||||
rm.logf("Deleting app: %s version %s", app.Name, app.Version)
|
||||
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: app.Organization,
|
||||
Name: app.Name,
|
||||
Version: app.Version,
|
||||
}
|
||||
|
||||
err := rm.client.DeleteApp(ctx, appKey, app.Region)
|
||||
actionResult := DeletionActionResult{
|
||||
Type: "app",
|
||||
Target: fmt.Sprintf("%s:%s", app.Name, app.Version),
|
||||
Duration: time.Since(actionStart),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
rm.logf("Failed to delete app %s: %v", app.Name, err)
|
||||
actionResult.Success = false
|
||||
actionResult.Error = err
|
||||
result.FailedActions = append(result.FailedActions, actionResult)
|
||||
result.Success = false
|
||||
result.Error = fmt.Errorf("failed to delete app %s: %w", app.Name, err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
rm.logf("Successfully deleted app: %s", app.Name)
|
||||
actionResult.Success = true
|
||||
result.CompletedActions = append(result.CompletedActions, actionResult)
|
||||
}
|
||||
|
||||
result.Duration = time.Since(startTime)
|
||||
rm.logf("Deletion completed successfully in %v", result.Duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// logf logs a message if a logger is configured
|
||||
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
|
||||
if rm.logger != nil {
|
||||
rm.logger.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
// ABOUTME: Deletion planner for EdgeConnect delete command
|
||||
// ABOUTME: Analyzes current state to identify resources for deletion
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
// EdgeConnectClientInterface defines the methods needed for deletion planning
|
||||
type EdgeConnectClientInterface interface {
|
||||
ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error)
|
||||
ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) ([]edgeconnect.AppInstance, error)
|
||||
DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error
|
||||
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error
|
||||
}
|
||||
|
||||
// Planner defines the interface for deletion planning
|
||||
type Planner interface {
|
||||
// Plan analyzes the configuration and current state to generate a deletion plan
|
||||
Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error)
|
||||
|
||||
// PlanWithOptions allows customization of planning behavior
|
||||
PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error)
|
||||
}
|
||||
|
||||
// PlanOptions provides configuration for the planning process
|
||||
type PlanOptions struct {
|
||||
// DryRun indicates this is a planning-only operation
|
||||
DryRun bool
|
||||
|
||||
// Timeout for API operations
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultPlanOptions returns sensible default planning options
|
||||
func DefaultPlanOptions() PlanOptions {
|
||||
return PlanOptions{
|
||||
DryRun: false,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
|
||||
type EdgeConnectPlanner struct {
|
||||
client EdgeConnectClientInterface
|
||||
}
|
||||
|
||||
// NewPlanner creates a new EdgeConnect deletion planner
|
||||
func NewPlanner(client EdgeConnectClientInterface) Planner {
|
||||
return &EdgeConnectPlanner{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// Plan analyzes the configuration and generates a deletion plan
|
||||
func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) {
|
||||
return p.PlanWithOptions(ctx, config, DefaultPlanOptions())
|
||||
}
|
||||
|
||||
// PlanWithOptions generates a deletion plan with custom options
|
||||
func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) {
|
||||
startTime := time.Now()
|
||||
var warnings []string
|
||||
|
||||
// Create the deletion plan structure
|
||||
plan := &DeletionPlan{
|
||||
ConfigName: config.Metadata.Name,
|
||||
CreatedAt: startTime,
|
||||
DryRun: opts.DryRun,
|
||||
}
|
||||
|
||||
// Get the region from the first infra template
|
||||
region := config.Spec.InfraTemplate[0].Region
|
||||
|
||||
// Step 1: Check if instances exist
|
||||
instancesResult := p.findInstancesToDelete(ctx, config, region)
|
||||
plan.InstancesToDelete = instancesResult.instances
|
||||
if instancesResult.err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("Error querying instances: %v", instancesResult.err))
|
||||
}
|
||||
|
||||
// Step 2: Check if app exists
|
||||
appResult := p.findAppToDelete(ctx, config, region)
|
||||
plan.AppToDelete = appResult.app
|
||||
if appResult.err != nil && !isNotFoundError(appResult.err) {
|
||||
warnings = append(warnings, fmt.Sprintf("Error querying app: %v", appResult.err))
|
||||
}
|
||||
|
||||
// Step 3: Calculate plan metadata
|
||||
p.calculatePlanMetadata(plan)
|
||||
|
||||
// Step 4: Generate summary
|
||||
plan.Summary = plan.GenerateSummary()
|
||||
|
||||
return &PlanResult{
|
||||
Plan: plan,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type appQueryResult struct {
|
||||
app *AppDeletion
|
||||
err error
|
||||
}
|
||||
|
||||
type instancesQueryResult struct {
|
||||
instances []InstanceDeletion
|
||||
err error
|
||||
}
|
||||
|
||||
// findAppToDelete checks if the app exists and should be deleted
|
||||
func (p *EdgeConnectPlanner) findAppToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) appQueryResult {
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: config.Metadata.Organization,
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Metadata.AppVersion,
|
||||
}
|
||||
|
||||
app, err := p.client.ShowApp(ctx, appKey, region)
|
||||
if err != nil {
|
||||
if isNotFoundError(err) {
|
||||
return appQueryResult{app: nil, err: nil}
|
||||
}
|
||||
return appQueryResult{app: nil, err: err}
|
||||
}
|
||||
|
||||
return appQueryResult{
|
||||
app: &AppDeletion{
|
||||
Name: app.Key.Name,
|
||||
Version: app.Key.Version,
|
||||
Organization: app.Key.Organization,
|
||||
Region: region,
|
||||
},
|
||||
err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// findInstancesToDelete finds all instances that match the config
|
||||
func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) instancesQueryResult {
|
||||
var allInstances []InstanceDeletion
|
||||
|
||||
// Query instances for each infra template
|
||||
for _, infra := range config.Spec.InfraTemplate {
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: config.Metadata.Organization,
|
||||
Name: generateInstanceName(config.Metadata.Name, config.Metadata.AppVersion),
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: infra.CloudletOrg,
|
||||
Name: infra.CloudletName,
|
||||
},
|
||||
}
|
||||
appKey := edgeconnect.AppKey{Name: config.Metadata.Name}
|
||||
|
||||
instances, err := p.client.ShowAppInstances(ctx, instanceKey, appKey, infra.Region)
|
||||
if err != nil {
|
||||
// If it's a not found error, just continue
|
||||
if isNotFoundError(err) {
|
||||
continue
|
||||
}
|
||||
return instancesQueryResult{instances: nil, err: err}
|
||||
}
|
||||
|
||||
// Add found instances to the list
|
||||
for _, inst := range instances {
|
||||
allInstances = append(allInstances, InstanceDeletion{
|
||||
Name: inst.Key.Name,
|
||||
Organization: inst.Key.Organization,
|
||||
Region: infra.Region,
|
||||
CloudletOrg: inst.Key.CloudletKey.Organization,
|
||||
CloudletName: inst.Key.CloudletKey.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return instancesQueryResult{
|
||||
instances: allInstances,
|
||||
err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// calculatePlanMetadata calculates the total actions and estimated duration
|
||||
func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeletionPlan) {
|
||||
totalActions := 0
|
||||
|
||||
if plan.AppToDelete != nil {
|
||||
totalActions++
|
||||
}
|
||||
|
||||
totalActions += len(plan.InstancesToDelete)
|
||||
|
||||
plan.TotalActions = totalActions
|
||||
|
||||
// Estimate duration: ~5 seconds per instance, ~3 seconds for app
|
||||
estimatedSeconds := len(plan.InstancesToDelete) * 5
|
||||
if plan.AppToDelete != nil {
|
||||
estimatedSeconds += 3
|
||||
}
|
||||
plan.EstimatedDuration = time.Duration(estimatedSeconds) * time.Second
|
||||
}
|
||||
|
||||
// generateInstanceName creates an instance name from app name and version
|
||||
func generateInstanceName(appName, appVersion string) string {
|
||||
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
|
||||
}
|
||||
|
||||
// isNotFoundError checks if an error is a 404 not found error
|
||||
func isNotFoundError(err error) bool {
|
||||
if apiErr, ok := err.(*edgeconnect.APIError); ok {
|
||||
return apiErr.StatusCode == 404
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PlanResult represents the result of a deletion planning operation
|
||||
type PlanResult struct {
|
||||
// Plan is the generated deletion plan
|
||||
Plan *DeletionPlan
|
||||
|
||||
// Error if planning failed
|
||||
Error error
|
||||
|
||||
// Warnings encountered during planning
|
||||
Warnings []string
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
// ABOUTME: Deletion planning types for EdgeConnect delete command
|
||||
// ABOUTME: Defines structures for deletion plans and deletion results
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeletionPlan represents the complete deletion plan for a configuration
|
||||
type DeletionPlan struct {
|
||||
// ConfigName is the name from metadata
|
||||
ConfigName string
|
||||
|
||||
// AppToDelete defines the app that will be deleted (nil if app doesn't exist)
|
||||
AppToDelete *AppDeletion
|
||||
|
||||
// InstancesToDelete defines the instances that will be deleted
|
||||
InstancesToDelete []InstanceDeletion
|
||||
|
||||
// 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 deletion
|
||||
EstimatedDuration time.Duration
|
||||
|
||||
// CreatedAt timestamp when the plan was created
|
||||
CreatedAt time.Time
|
||||
|
||||
// DryRun indicates if this is a dry-run plan
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// AppDeletion represents an application to be deleted
|
||||
type AppDeletion 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
|
||||
}
|
||||
|
||||
// InstanceDeletion represents an application instance to be deleted
|
||||
type InstanceDeletion struct {
|
||||
// Name of the instance
|
||||
Name 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
|
||||
}
|
||||
|
||||
// DeletionResult represents the result of a deletion operation
|
||||
type DeletionResult struct {
|
||||
// Plan that was executed
|
||||
Plan *DeletionPlan
|
||||
|
||||
// Success indicates if the deletion was successful
|
||||
Success bool
|
||||
|
||||
// CompletedActions lists actions that were successfully completed
|
||||
CompletedActions []DeletionActionResult
|
||||
|
||||
// FailedActions lists actions that failed
|
||||
FailedActions []DeletionActionResult
|
||||
|
||||
// Error that caused the deletion to fail (if any)
|
||||
Error error
|
||||
|
||||
// Duration taken to execute the plan
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// DeletionActionResult represents the result of executing a single deletion action
|
||||
type DeletionActionResult struct {
|
||||
// Type of resource that was deleted ("app" or "instance")
|
||||
Type string
|
||||
|
||||
// Target describes what was being deleted
|
||||
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
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the deletion plan has no actions to perform
|
||||
func (dp *DeletionPlan) IsEmpty() bool {
|
||||
return dp.AppToDelete == nil && len(dp.InstancesToDelete) == 0
|
||||
}
|
||||
|
||||
// GenerateSummary creates a human-readable summary of the deletion plan
|
||||
func (dp *DeletionPlan) GenerateSummary() string {
|
||||
if dp.IsEmpty() {
|
||||
return "No resources found to delete"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Deletion plan for '%s':\n", dp.ConfigName))
|
||||
|
||||
// Instance actions
|
||||
if len(dp.InstancesToDelete) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- DELETE %d instance(s)\n", len(dp.InstancesToDelete)))
|
||||
cloudletSet := make(map[string]bool)
|
||||
for _, inst := range dp.InstancesToDelete {
|
||||
key := fmt.Sprintf("%s:%s", inst.CloudletOrg, inst.CloudletName)
|
||||
cloudletSet[key] = true
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Across %d cloudlet(s)\n", len(cloudletSet)))
|
||||
}
|
||||
|
||||
// App action
|
||||
if dp.AppToDelete != nil {
|
||||
sb.WriteString(fmt.Sprintf("- DELETE application '%s' version %s\n",
|
||||
dp.AppToDelete.Name, dp.AppToDelete.Version))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Validate checks if the deletion plan is valid
|
||||
func (dp *DeletionPlan) Validate() error {
|
||||
if dp.ConfigName == "" {
|
||||
return fmt.Errorf("deletion plan must have a config name")
|
||||
}
|
||||
|
||||
if dp.IsEmpty() {
|
||||
return fmt.Errorf("deletion plan has no resources to delete")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
// ABOUTME: Resource management for EdgeConnect delete command with deletion execution
|
||||
// ABOUTME: Handles actual deletion operations with proper ordering (instances first, then app)
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
// ResourceManagerInterface defines the interface for resource management
|
||||
type ResourceManagerInterface interface {
|
||||
// ExecuteDeletion executes a deletion plan
|
||||
ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error)
|
||||
}
|
||||
|
||||
// EdgeConnectResourceManager implements resource management for EdgeConnect
|
||||
type EdgeConnectResourceManager struct {
|
||||
client EdgeConnectClientInterface
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// Logger interface for deletion logging
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
// ResourceManagerOptions configures the resource manager behavior
|
||||
type ResourceManagerOptions struct {
|
||||
// Logger for deletion operations
|
||||
Logger Logger
|
||||
}
|
||||
|
||||
// DefaultResourceManagerOptions returns sensible defaults
|
||||
func DefaultResourceManagerOptions() ResourceManagerOptions {
|
||||
return ResourceManagerOptions{
|
||||
Logger: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// NewResourceManager creates a new EdgeConnect resource manager
|
||||
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
||||
options := DefaultResourceManagerOptions()
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
return &EdgeConnectResourceManager{
|
||||
client: client,
|
||||
logger: options.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets a logger for deletion operations
|
||||
func WithLogger(logger Logger) func(*ResourceManagerOptions) {
|
||||
return func(opts *ResourceManagerOptions) {
|
||||
opts.Logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteDeletion executes a deletion plan
|
||||
// Important: Instances must be deleted before the app
|
||||
func (rm *EdgeConnectResourceManager) ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error) {
|
||||
startTime := time.Now()
|
||||
rm.logf("Starting deletion: %s", plan.ConfigName)
|
||||
|
||||
result := &DeletionResult{
|
||||
Plan: plan,
|
||||
Success: true,
|
||||
CompletedActions: []DeletionActionResult{},
|
||||
FailedActions: []DeletionActionResult{},
|
||||
}
|
||||
|
||||
// If plan is empty, return success immediately
|
||||
if plan.IsEmpty() {
|
||||
rm.logf("No resources to delete")
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Step 1: Delete all instances first
|
||||
for _, instance := range plan.InstancesToDelete {
|
||||
actionStart := time.Now()
|
||||
rm.logf("Deleting instance: %s", instance.Name)
|
||||
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: instance.Organization,
|
||||
Name: instance.Name,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: instance.CloudletOrg,
|
||||
Name: instance.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
err := rm.client.DeleteAppInstance(ctx, instanceKey, instance.Region)
|
||||
actionResult := DeletionActionResult{
|
||||
Type: "instance",
|
||||
Target: instance.Name,
|
||||
Duration: time.Since(actionStart),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
rm.logf("Failed to delete instance %s: %v", instance.Name, err)
|
||||
actionResult.Success = false
|
||||
actionResult.Error = err
|
||||
result.FailedActions = append(result.FailedActions, actionResult)
|
||||
result.Success = false
|
||||
result.Error = fmt.Errorf("failed to delete instance %s: %w", instance.Name, err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
rm.logf("Successfully deleted instance: %s", instance.Name)
|
||||
actionResult.Success = true
|
||||
result.CompletedActions = append(result.CompletedActions, actionResult)
|
||||
}
|
||||
|
||||
// Step 2: Delete the app (only after all instances are deleted)
|
||||
if plan.AppToDelete != nil {
|
||||
actionStart := time.Now()
|
||||
app := plan.AppToDelete
|
||||
rm.logf("Deleting app: %s version %s", app.Name, app.Version)
|
||||
|
||||
appKey := v2.AppKey{
|
||||
Organization: app.Organization,
|
||||
Name: app.Name,
|
||||
Version: app.Version,
|
||||
}
|
||||
|
||||
err := rm.client.DeleteApp(ctx, appKey, app.Region)
|
||||
actionResult := DeletionActionResult{
|
||||
Type: "app",
|
||||
Target: fmt.Sprintf("%s:%s", app.Name, app.Version),
|
||||
Duration: time.Since(actionStart),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
rm.logf("Failed to delete app %s: %v", app.Name, err)
|
||||
actionResult.Success = false
|
||||
actionResult.Error = err
|
||||
result.FailedActions = append(result.FailedActions, actionResult)
|
||||
result.Success = false
|
||||
result.Error = fmt.Errorf("failed to delete app %s: %w", app.Name, err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
rm.logf("Successfully deleted app: %s", app.Name)
|
||||
actionResult.Success = true
|
||||
result.CompletedActions = append(result.CompletedActions, actionResult)
|
||||
}
|
||||
|
||||
result.Duration = time.Since(startTime)
|
||||
rm.logf("Deletion completed successfully in %v", result.Duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// logf logs a message if a logger is configured
|
||||
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
|
||||
if rm.logger != nil {
|
||||
rm.logger.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
// ABOUTME: Tests for EdgeConnect deletion manager with mock scenarios
|
||||
// ABOUTME: Tests deletion execution and error handling with mock clients
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockResourceClient for testing deletion manager
|
||||
type MockResourceClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return v2.App{}, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(v2.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]v2.AppInstance), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// TestLogger implements Logger interface for testing
|
||||
type TestLogger struct {
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (l *TestLogger) Printf(format string, v ...interface{}) {
|
||||
l.messages = append(l.messages, fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
func TestNewResourceManager(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
|
||||
assert.NotNil(t, manager)
|
||||
}
|
||||
|
||||
func TestWithLogger(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger))
|
||||
|
||||
// Cast to implementation to check logger was set
|
||||
impl := manager.(*EdgeConnectResourceManager)
|
||||
assert.Equal(t, logger, impl.logger)
|
||||
}
|
||||
|
||||
func createTestDeletionPlan() *DeletionPlan {
|
||||
return &DeletionPlan{
|
||||
ConfigName: "test-deletion",
|
||||
AppToDelete: &AppDeletion{
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
},
|
||||
InstancesToDelete: []InstanceDeletion{
|
||||
{
|
||||
Name: "test-app-1.0.0-instance",
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
CloudletOrg: "cloudletorg",
|
||||
CloudletName: "cloudlet1",
|
||||
},
|
||||
},
|
||||
TotalActions: 2,
|
||||
EstimatedDuration: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDeletion_Success(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger))
|
||||
|
||||
plan := createTestDeletionPlan()
|
||||
|
||||
// Mock successful deletion operations
|
||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(nil)
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ExecuteDeletion(ctx, plan)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 2) // 1 instance + 1 app
|
||||
assert.Len(t, result.FailedActions, 0)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestExecuteDeletion_InstanceDeleteFails(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger))
|
||||
|
||||
plan := createTestDeletionPlan()
|
||||
|
||||
// Mock instance deletion failure
|
||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(fmt.Errorf("instance deletion failed"))
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ExecuteDeletion(ctx, plan)
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.Success)
|
||||
assert.Len(t, result.FailedActions, 1)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestExecuteDeletion_OnlyInstances(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger))
|
||||
|
||||
plan := &DeletionPlan{
|
||||
ConfigName: "test-deletion",
|
||||
AppToDelete: nil, // No app to delete
|
||||
InstancesToDelete: []InstanceDeletion{
|
||||
{
|
||||
Name: "test-app-1.0.0-instance",
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
CloudletOrg: "cloudletorg",
|
||||
CloudletName: "cloudlet1",
|
||||
},
|
||||
},
|
||||
TotalActions: 1,
|
||||
EstimatedDuration: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Mock successful instance deletion
|
||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ExecuteDeletion(ctx, plan)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 1)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestExecuteDeletion_EmptyPlan(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
|
||||
plan := &DeletionPlan{
|
||||
ConfigName: "test-deletion",
|
||||
AppToDelete: nil,
|
||||
InstancesToDelete: []InstanceDeletion{},
|
||||
TotalActions: 0,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ExecuteDeletion(ctx, plan)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 0)
|
||||
assert.Len(t, result.FailedActions, 0)
|
||||
}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
// ABOUTME: Deletion planner for EdgeConnect delete command
|
||||
// ABOUTME: Analyzes current state to identify resources for deletion
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
// EdgeConnectClientInterface defines the methods needed for deletion planning
|
||||
type EdgeConnectClientInterface interface {
|
||||
ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error)
|
||||
ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error)
|
||||
DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error
|
||||
DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error
|
||||
}
|
||||
|
||||
// Planner defines the interface for deletion planning
|
||||
type Planner interface {
|
||||
// Plan analyzes the configuration and current state to generate a deletion plan
|
||||
Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error)
|
||||
|
||||
// PlanWithOptions allows customization of planning behavior
|
||||
PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error)
|
||||
}
|
||||
|
||||
// PlanOptions provides configuration for the planning process
|
||||
type PlanOptions struct {
|
||||
// DryRun indicates this is a planning-only operation
|
||||
DryRun bool
|
||||
|
||||
// Timeout for API operations
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultPlanOptions returns sensible default planning options
|
||||
func DefaultPlanOptions() PlanOptions {
|
||||
return PlanOptions{
|
||||
DryRun: false,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
|
||||
type EdgeConnectPlanner struct {
|
||||
client EdgeConnectClientInterface
|
||||
}
|
||||
|
||||
// NewPlanner creates a new EdgeConnect deletion planner
|
||||
func NewPlanner(client EdgeConnectClientInterface) Planner {
|
||||
return &EdgeConnectPlanner{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// Plan analyzes the configuration and generates a deletion plan
|
||||
func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) {
|
||||
return p.PlanWithOptions(ctx, config, DefaultPlanOptions())
|
||||
}
|
||||
|
||||
// PlanWithOptions generates a deletion plan with custom options
|
||||
func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) {
|
||||
startTime := time.Now()
|
||||
var warnings []string
|
||||
|
||||
// Create the deletion plan structure
|
||||
plan := &DeletionPlan{
|
||||
ConfigName: config.Metadata.Name,
|
||||
CreatedAt: startTime,
|
||||
DryRun: opts.DryRun,
|
||||
}
|
||||
|
||||
// Get the region from the first infra template
|
||||
region := config.Spec.InfraTemplate[0].Region
|
||||
|
||||
// Step 1: Check if instances exist
|
||||
instancesResult := p.findInstancesToDelete(ctx, config, region)
|
||||
plan.InstancesToDelete = instancesResult.instances
|
||||
if instancesResult.err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("Error querying instances: %v", instancesResult.err))
|
||||
}
|
||||
|
||||
// Step 2: Check if app exists
|
||||
appResult := p.findAppToDelete(ctx, config, region)
|
||||
plan.AppToDelete = appResult.app
|
||||
if appResult.err != nil && !isNotFoundError(appResult.err) {
|
||||
warnings = append(warnings, fmt.Sprintf("Error querying app: %v", appResult.err))
|
||||
}
|
||||
|
||||
// Step 3: Calculate plan metadata
|
||||
p.calculatePlanMetadata(plan)
|
||||
|
||||
// Step 4: Generate summary
|
||||
plan.Summary = plan.GenerateSummary()
|
||||
|
||||
return &PlanResult{
|
||||
Plan: plan,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type appQueryResult struct {
|
||||
app *AppDeletion
|
||||
err error
|
||||
}
|
||||
|
||||
type instancesQueryResult struct {
|
||||
instances []InstanceDeletion
|
||||
err error
|
||||
}
|
||||
|
||||
// findAppToDelete checks if the app exists and should be deleted
|
||||
func (p *EdgeConnectPlanner) findAppToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) appQueryResult {
|
||||
appKey := v2.AppKey{
|
||||
Organization: config.Metadata.Organization,
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Metadata.AppVersion,
|
||||
}
|
||||
|
||||
app, err := p.client.ShowApp(ctx, appKey, region)
|
||||
if err != nil {
|
||||
if isNotFoundError(err) {
|
||||
return appQueryResult{app: nil, err: nil}
|
||||
}
|
||||
return appQueryResult{app: nil, err: err}
|
||||
}
|
||||
|
||||
return appQueryResult{
|
||||
app: &AppDeletion{
|
||||
Name: app.Key.Name,
|
||||
Version: app.Key.Version,
|
||||
Organization: app.Key.Organization,
|
||||
Region: region,
|
||||
},
|
||||
err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// findInstancesToDelete finds all instances that match the config
|
||||
func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) instancesQueryResult {
|
||||
var allInstances []InstanceDeletion
|
||||
|
||||
// Query instances for each infra template
|
||||
for _, infra := range config.Spec.InfraTemplate {
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: config.Metadata.Organization,
|
||||
Name: generateInstanceName(config.Metadata.Name, config.Metadata.AppVersion),
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: infra.CloudletOrg,
|
||||
Name: infra.CloudletName,
|
||||
},
|
||||
}
|
||||
appKey := v2.AppKey{Name: config.Metadata.Name}
|
||||
|
||||
instances, err := p.client.ShowAppInstances(ctx, instanceKey, appKey, infra.Region)
|
||||
if err != nil {
|
||||
// If it's a not found error, just continue
|
||||
if isNotFoundError(err) {
|
||||
continue
|
||||
}
|
||||
return instancesQueryResult{instances: nil, err: err}
|
||||
}
|
||||
|
||||
// Add found instances to the list
|
||||
for _, inst := range instances {
|
||||
allInstances = append(allInstances, InstanceDeletion{
|
||||
Name: inst.Key.Name,
|
||||
Organization: inst.Key.Organization,
|
||||
Region: infra.Region,
|
||||
CloudletOrg: inst.Key.CloudletKey.Organization,
|
||||
CloudletName: inst.Key.CloudletKey.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return instancesQueryResult{
|
||||
instances: allInstances,
|
||||
err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// calculatePlanMetadata calculates the total actions and estimated duration
|
||||
func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeletionPlan) {
|
||||
totalActions := 0
|
||||
|
||||
if plan.AppToDelete != nil {
|
||||
totalActions++
|
||||
}
|
||||
|
||||
totalActions += len(plan.InstancesToDelete)
|
||||
|
||||
plan.TotalActions = totalActions
|
||||
|
||||
// Estimate duration: ~5 seconds per instance, ~3 seconds for app
|
||||
estimatedSeconds := len(plan.InstancesToDelete) * 5
|
||||
if plan.AppToDelete != nil {
|
||||
estimatedSeconds += 3
|
||||
}
|
||||
plan.EstimatedDuration = time.Duration(estimatedSeconds) * time.Second
|
||||
}
|
||||
|
||||
// generateInstanceName creates an instance name from app name and version
|
||||
func generateInstanceName(appName, appVersion string) string {
|
||||
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
|
||||
}
|
||||
|
||||
// isNotFoundError checks if an error is a 404 not found error
|
||||
func isNotFoundError(err error) bool {
|
||||
if apiErr, ok := err.(*v2.APIError); ok {
|
||||
return apiErr.StatusCode == 404
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PlanResult represents the result of a deletion planning operation
|
||||
type PlanResult struct {
|
||||
// Plan is the generated deletion plan
|
||||
Plan *DeletionPlan
|
||||
|
||||
// Error if planning failed
|
||||
Error error
|
||||
|
||||
// Warnings encountered during planning
|
||||
Warnings []string
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
// ABOUTME: Tests for EdgeConnect deletion planner with mock scenarios
|
||||
// ABOUTME: Tests deletion planning logic and resource discovery
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"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 v2.AppKey, region string) (v2.App, error) {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return v2.App{}, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(v2.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]v2.AppInstance), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPlanner(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
|
||||
assert.NotNil(t, planner)
|
||||
}
|
||||
|
||||
func TestPlanDeletion_WithExistingResources(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
testConfig := createTestConfig(t)
|
||||
|
||||
// Mock existing app
|
||||
existingApp := v2.App{
|
||||
Key: v2.AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Deployment: "kubernetes",
|
||||
}
|
||||
|
||||
// Mock existing instances
|
||||
existingInstances := []v2.AppInstance{
|
||||
{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app-1.0.0-instance",
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: "TestCloudletOrg",
|
||||
Name: "TestCloudlet",
|
||||
},
|
||||
},
|
||||
AppKey: v2.AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(existingApp, nil)
|
||||
|
||||
mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(existingInstances, 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, "test-app", plan.ConfigName)
|
||||
assert.NotNil(t, plan.AppToDelete)
|
||||
assert.Equal(t, "test-app", plan.AppToDelete.Name)
|
||||
assert.Equal(t, "1.0.0", plan.AppToDelete.Version)
|
||||
assert.Equal(t, "testorg", plan.AppToDelete.Organization)
|
||||
|
||||
require.Len(t, plan.InstancesToDelete, 1)
|
||||
assert.Equal(t, "test-app-1.0.0-instance", plan.InstancesToDelete[0].Name)
|
||||
assert.Equal(t, "testorg", plan.InstancesToDelete[0].Organization)
|
||||
|
||||
assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance
|
||||
assert.False(t, plan.IsEmpty())
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestPlanDeletion_NoResourcesExist(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("v2.AppKey"), "US").
|
||||
Return(v2.App{}, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
||||
|
||||
mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return([]v2.AppInstance{}, 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, "test-app", plan.ConfigName)
|
||||
assert.Nil(t, plan.AppToDelete)
|
||||
assert.Len(t, plan.InstancesToDelete, 0)
|
||||
assert.Equal(t, 0, plan.TotalActions)
|
||||
assert.True(t, plan.IsEmpty())
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestPlanDeletion_OnlyInstancesExist(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
testConfig := createTestConfig(t)
|
||||
|
||||
// Mock existing instances but no app
|
||||
existingInstances := []v2.AppInstance{
|
||||
{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app-1.0.0-instance",
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: "TestCloudletOrg",
|
||||
Name: "TestCloudlet",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(v2.App{}, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
||||
|
||||
mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(existingInstances, 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.Nil(t, plan.AppToDelete)
|
||||
assert.Len(t, plan.InstancesToDelete, 1)
|
||||
assert.Equal(t, 1, plan.TotalActions)
|
||||
assert.False(t, plan.IsEmpty())
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
// ABOUTME: Deletion planning types for EdgeConnect delete command
|
||||
// ABOUTME: Defines structures for deletion plans and deletion results
|
||||
package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeletionPlan represents the complete deletion plan for a configuration
|
||||
type DeletionPlan struct {
|
||||
// ConfigName is the name from metadata
|
||||
ConfigName string
|
||||
|
||||
// AppToDelete defines the app that will be deleted (nil if app doesn't exist)
|
||||
AppToDelete *AppDeletion
|
||||
|
||||
// InstancesToDelete defines the instances that will be deleted
|
||||
InstancesToDelete []InstanceDeletion
|
||||
|
||||
// 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 deletion
|
||||
EstimatedDuration time.Duration
|
||||
|
||||
// CreatedAt timestamp when the plan was created
|
||||
CreatedAt time.Time
|
||||
|
||||
// DryRun indicates if this is a dry-run plan
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// AppDeletion represents an application to be deleted
|
||||
type AppDeletion 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
|
||||
}
|
||||
|
||||
// InstanceDeletion represents an application instance to be deleted
|
||||
type InstanceDeletion struct {
|
||||
// Name of the instance
|
||||
Name 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
|
||||
}
|
||||
|
||||
// DeletionResult represents the result of a deletion operation
|
||||
type DeletionResult struct {
|
||||
// Plan that was executed
|
||||
Plan *DeletionPlan
|
||||
|
||||
// Success indicates if the deletion was successful
|
||||
Success bool
|
||||
|
||||
// CompletedActions lists actions that were successfully completed
|
||||
CompletedActions []DeletionActionResult
|
||||
|
||||
// FailedActions lists actions that failed
|
||||
FailedActions []DeletionActionResult
|
||||
|
||||
// Error that caused the deletion to fail (if any)
|
||||
Error error
|
||||
|
||||
// Duration taken to execute the plan
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// DeletionActionResult represents the result of executing a single deletion action
|
||||
type DeletionActionResult struct {
|
||||
// Type of resource that was deleted ("app" or "instance")
|
||||
Type string
|
||||
|
||||
// Target describes what was being deleted
|
||||
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
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the deletion plan has no actions to perform
|
||||
func (dp *DeletionPlan) IsEmpty() bool {
|
||||
return dp.AppToDelete == nil && len(dp.InstancesToDelete) == 0
|
||||
}
|
||||
|
||||
// GenerateSummary creates a human-readable summary of the deletion plan
|
||||
func (dp *DeletionPlan) GenerateSummary() string {
|
||||
if dp.IsEmpty() {
|
||||
return "No resources found to delete"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Deletion plan for '%s':\n", dp.ConfigName))
|
||||
|
||||
// Instance actions
|
||||
if len(dp.InstancesToDelete) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- DELETE %d instance(s)\n", len(dp.InstancesToDelete)))
|
||||
cloudletSet := make(map[string]bool)
|
||||
for _, inst := range dp.InstancesToDelete {
|
||||
key := fmt.Sprintf("%s:%s", inst.CloudletOrg, inst.CloudletName)
|
||||
cloudletSet[key] = true
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Across %d cloudlet(s)\n", len(cloudletSet)))
|
||||
}
|
||||
|
||||
// App action
|
||||
if dp.AppToDelete != nil {
|
||||
sb.WriteString(fmt.Sprintf("- DELETE application '%s' version %s\n",
|
||||
dp.AppToDelete.Name, dp.AppToDelete.Version))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Validate checks if the deletion plan is valid
|
||||
func (dp *DeletionPlan) Validate() error {
|
||||
if dp.ConfigName == "" {
|
||||
return fmt.Errorf("deletion plan must have a config name")
|
||||
}
|
||||
|
||||
if dp.IsEmpty() {
|
||||
return fmt.Errorf("deletion plan has no resources to delete")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
package v2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeletionPlan_IsEmpty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
plan *DeletionPlan
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "empty plan with no resources",
|
||||
plan: &DeletionPlan{
|
||||
ConfigName: "test-config",
|
||||
AppToDelete: nil,
|
||||
InstancesToDelete: []InstanceDeletion{},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "plan with app deletion",
|
||||
plan: &DeletionPlan{
|
||||
ConfigName: "test-config",
|
||||
AppToDelete: &AppDeletion{
|
||||
Name: "test-app",
|
||||
Organization: "test-org",
|
||||
Version: "1.0",
|
||||
Region: "US",
|
||||
},
|
||||
InstancesToDelete: []InstanceDeletion{},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "plan with instance deletion",
|
||||
plan: &DeletionPlan{
|
||||
ConfigName: "test-config",
|
||||
AppToDelete: nil,
|
||||
InstancesToDelete: []InstanceDeletion{
|
||||
{
|
||||
Name: "test-instance",
|
||||
Organization: "test-org",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.plan.IsEmpty()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletionPlan_GenerateSummary(t *testing.T) {
|
||||
plan := &DeletionPlan{
|
||||
ConfigName: "test-config",
|
||||
AppToDelete: &AppDeletion{
|
||||
Name: "test-app",
|
||||
Organization: "test-org",
|
||||
Version: "1.0",
|
||||
Region: "US",
|
||||
},
|
||||
InstancesToDelete: []InstanceDeletion{
|
||||
{
|
||||
Name: "test-instance-1",
|
||||
Organization: "test-org",
|
||||
CloudletName: "cloudlet-1",
|
||||
CloudletOrg: "cloudlet-org",
|
||||
},
|
||||
{
|
||||
Name: "test-instance-2",
|
||||
Organization: "test-org",
|
||||
CloudletName: "cloudlet-2",
|
||||
CloudletOrg: "cloudlet-org",
|
||||
},
|
||||
},
|
||||
TotalActions: 3,
|
||||
EstimatedDuration: 30 * time.Second,
|
||||
}
|
||||
|
||||
summary := plan.GenerateSummary()
|
||||
|
||||
assert.Contains(t, summary, "test-config")
|
||||
assert.Contains(t, summary, "DELETE application 'test-app'")
|
||||
assert.Contains(t, summary, "DELETE 2 instance(s)")
|
||||
}
|
||||
2
main.go
2
main.go
|
|
@ -1,6 +1,6 @@
|
|||
package main
|
||||
|
||||
import "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/cmd"
|
||||
import "edp.buildth.ing/DevFW-CICD/edge-connect-client/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
|
|
|
|||
BIN
public.gpg
BIN
public.gpg
Binary file not shown.
|
|
@ -16,18 +16,18 @@ A comprehensive Go SDK for the EdgeXR Master Controller API, providing typed int
|
|||
### Installation
|
||||
|
||||
```go
|
||||
import v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
import "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
```go
|
||||
// Username/password (recommended)
|
||||
client := v2.NewClientWithCredentials(baseURL, username, password)
|
||||
client := client.NewClientWithCredentials(baseURL, username, password)
|
||||
|
||||
// Static Bearer token
|
||||
client := v2.NewClient(baseURL,
|
||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)))
|
||||
client := client.NewClient(baseURL,
|
||||
client.WithAuthProvider(client.NewStaticTokenProvider(token)))
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
|
@ -36,10 +36,10 @@ client := v2.NewClient(baseURL,
|
|||
ctx := context.Background()
|
||||
|
||||
// Create an application
|
||||
app := &v2.NewAppInput{
|
||||
app := &client.NewAppInput{
|
||||
Region: "us-west",
|
||||
App: v2.App{
|
||||
Key: v2.AppKey{
|
||||
App: client.App{
|
||||
Key: client.AppKey{
|
||||
Organization: "myorg",
|
||||
Name: "my-app",
|
||||
Version: "1.0.0",
|
||||
|
|
@ -49,28 +49,28 @@ app := &v2.NewAppInput{
|
|||
},
|
||||
}
|
||||
|
||||
if err := v2.CreateApp(ctx, app); err != nil {
|
||||
if err := client.CreateApp(ctx, app); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Deploy an application instance
|
||||
instance := &v2.NewAppInstanceInput{
|
||||
instance := &client.NewAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: v2.AppInstance{
|
||||
Key: v2.AppInstanceKey{
|
||||
AppInst: client.AppInstance{
|
||||
Key: client.AppInstanceKey{
|
||||
Organization: "myorg",
|
||||
Name: "my-instance",
|
||||
CloudletKey: v2.CloudletKey{
|
||||
CloudletKey: client.CloudletKey{
|
||||
Organization: "cloudlet-provider",
|
||||
Name: "edge-cloudlet",
|
||||
},
|
||||
},
|
||||
AppKey: app.App.Key,
|
||||
Flavor: v2.Flavor{Name: "m4.small"},
|
||||
Flavor: client.Flavor{Name: "m4.small"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := v2.CreateAppInstance(ctx, instance); err != nil {
|
||||
if err := client.CreateAppInstance(ctx, instance); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
|
@ -101,22 +101,22 @@ if err := v2.CreateAppInstance(ctx, instance); err != nil {
|
|||
## Configuration Options
|
||||
|
||||
```go
|
||||
client := v2.NewClient(baseURL,
|
||||
client := client.NewClient(baseURL,
|
||||
// Custom HTTP client with timeout
|
||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
|
||||
// Authentication provider
|
||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)),
|
||||
client.WithAuthProvider(client.NewStaticTokenProvider(token)),
|
||||
|
||||
// Retry configuration
|
||||
v2.WithRetryOptions(v2.RetryOptions{
|
||||
client.WithRetryOptions(client.RetryOptions{
|
||||
MaxRetries: 5,
|
||||
InitialDelay: 1 * time.Second,
|
||||
MaxDelay: 30 * time.Second,
|
||||
}),
|
||||
|
||||
// Request logging
|
||||
v2.WithLogger(log.Default()),
|
||||
client.WithLogger(log.Default()),
|
||||
)
|
||||
```
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ EDGEXR_USERNAME=user EDGEXR_PASSWORD=pass go run main.go
|
|||
Uses the existing `/api/v1/login` endpoint with automatic token caching:
|
||||
|
||||
```go
|
||||
client := v2.NewClientWithCredentials(baseURL, username, password)
|
||||
client := client.NewClientWithCredentials(baseURL, username, password)
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
|
@ -154,23 +154,23 @@ client := v2.NewClientWithCredentials(baseURL, username, password)
|
|||
For pre-obtained tokens:
|
||||
|
||||
```go
|
||||
client := v2.NewClient(baseURL,
|
||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)))
|
||||
client := client.NewClient(baseURL,
|
||||
client.WithAuthProvider(client.NewStaticTokenProvider(token)))
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```go
|
||||
app, err := v2.ShowApp(ctx, appKey, region)
|
||||
app, err := client.ShowApp(ctx, appKey, region)
|
||||
if err != nil {
|
||||
// Check for specific error types
|
||||
if errors.Is(err, v2.ErrResourceNotFound) {
|
||||
if errors.Is(err, client.ErrResourceNotFound) {
|
||||
fmt.Println("App not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for API errors
|
||||
var apiErr *v2.APIError
|
||||
var apiErr *client.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
fmt.Printf("API Error %d: %s\n", apiErr.StatusCode, apiErr.Messages[0])
|
||||
return
|
||||
|
|
@ -213,13 +213,13 @@ The SDK provides a drop-in replacement with enhanced features:
|
|||
|
||||
```go
|
||||
// Old approach
|
||||
oldClient := &v2.EdgeConnect{
|
||||
oldClient := &client.EdgeConnect{
|
||||
BaseURL: baseURL,
|
||||
Credentials: v2.Credentials{Username: user, Password: pass},
|
||||
Credentials: client.Credentials{Username: user, Password: pass},
|
||||
}
|
||||
|
||||
// New SDK approach
|
||||
newClient := v2.NewClientWithCredentials(baseURL, user, pass)
|
||||
newClient := client.NewClientWithCredentials(baseURL, user, pass)
|
||||
|
||||
// Same method calls, enhanced reliability
|
||||
err := newClient.CreateApp(ctx, input)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http"
|
||||
)
|
||||
|
||||
// CreateAppInstance creates a new application instance in the specified region
|
||||
|
|
@ -23,9 +23,7 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp
|
|||
if err != nil {
|
||||
return fmt.Errorf("CreateAppInstance failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "CreateAppInstance")
|
||||
|
|
@ -45,12 +43,12 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp
|
|||
|
||||
// ShowAppInstance retrieves a single application instance by key and region
|
||||
// Maps to POST /auth/ctrl/ShowAppInst
|
||||
func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) (AppInstance, error) {
|
||||
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{AppKey: appKey, Key: appInstKey},
|
||||
AppInstance: AppInstance{Key: appInstKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
|
|
@ -58,9 +56,7 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey,
|
|||
if err != nil {
|
||||
return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return AppInstance{}, fmt.Errorf("app instance %s/%s: %w",
|
||||
|
|
@ -87,12 +83,12 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey,
|
|||
|
||||
// ShowAppInstances retrieves all application instances matching the filter criteria
|
||||
// Maps to POST /auth/ctrl/ShowAppInst
|
||||
func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) ([]AppInstance, error) {
|
||||
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, AppKey: appKey},
|
||||
AppInstance: AppInstance{Key: appInstKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
|
|
@ -100,9 +96,7 @@ func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return nil, c.handleErrorResponse(resp, "ShowAppInstances")
|
||||
|
|
@ -131,9 +125,7 @@ func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstance
|
|||
if err != nil {
|
||||
return fmt.Errorf("UpdateAppInstance failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "UpdateAppInstance")
|
||||
|
|
@ -160,9 +152,7 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK
|
|||
if err != nil {
|
||||
return fmt.Errorf("RefreshAppInstance failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "RefreshAppInstance")
|
||||
|
|
@ -189,9 +179,7 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe
|
|||
if err != nil {
|
||||
return fmt.Errorf("DeleteAppInstance failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 404 is acceptable for delete operations (already deleted)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
|
|
@ -213,10 +201,6 @@ func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result i
|
|||
var errorMessage string
|
||||
|
||||
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
|
||||
// On permission denied, Edge API returns just an empty array []!
|
||||
if len(line) == 0 || line[0] == '[' {
|
||||
return fmt.Errorf("%w", ErrFaultyResponsePerhaps403)
|
||||
}
|
||||
// Try parsing as ResultResponse first (error format)
|
||||
var resultResp ResultResponse
|
||||
if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" {
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ func TestCreateAppInstance(t *testing.T) {
|
|||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -156,7 +156,6 @@ func TestCreateAppInstance(t *testing.T) {
|
|||
func TestShowAppInstance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appKey AppKey
|
||||
appInstKey AppInstanceKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
|
|
@ -174,7 +173,6 @@ func TestShowAppInstance(t *testing.T) {
|
|||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
appKey: AppKey{Name: "test-app-id"},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}}
|
||||
|
|
@ -192,7 +190,6 @@ func TestShowAppInstance(t *testing.T) {
|
|||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
appKey: AppKey{Name: "test-app-id"},
|
||||
region: "us-west",
|
||||
mockStatusCode: 404,
|
||||
mockResponse: "",
|
||||
|
|
@ -210,7 +207,7 @@ func TestShowAppInstance(t *testing.T) {
|
|||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
|
@ -222,7 +219,7 @@ func TestShowAppInstance(t *testing.T) {
|
|||
|
||||
// Execute test
|
||||
ctx := context.Background()
|
||||
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region)
|
||||
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region)
|
||||
|
||||
// Verify results
|
||||
if tt.expectError {
|
||||
|
|
@ -257,14 +254,14 @@ func TestShowAppInstances(t *testing.T) {
|
|||
{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}}
|
||||
`
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(response))
|
||||
w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west")
|
||||
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, "us-west")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, appInstances, 2)
|
||||
|
|
@ -364,7 +361,7 @@ func TestUpdateAppInstance(t *testing.T) {
|
|||
assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,12 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/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")
|
||||
ErrFaultyResponsePerhaps403 = fmt.Errorf("faulty response from API, may indicate permission denied")
|
||||
ErrResourceNotFound = fmt.Errorf("resource not found")
|
||||
)
|
||||
|
||||
// CreateApp creates a new application in the specified region
|
||||
|
|
@ -29,9 +28,7 @@ func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("CreateApp failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "CreateApp")
|
||||
|
|
@ -58,9 +55,7 @@ func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App
|
|||
if err != nil {
|
||||
return App{}, fmt.Errorf("ShowApp failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
|
||||
|
|
@ -100,9 +95,7 @@ func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("ShowApps failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return nil, c.handleErrorResponse(resp, "ShowApps")
|
||||
|
|
@ -131,9 +124,7 @@ func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("UpdateApp failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "UpdateApp")
|
||||
|
|
@ -160,9 +151,7 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er
|
|||
if err != nil {
|
||||
return fmt.Errorf("DeleteApp failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 404 is acceptable for delete operations (already deleted)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
|
|
@ -180,10 +169,6 @@ func (c *Client) parseStreamingResponse(resp *http.Response, result interface{})
|
|||
var responses []Response[App]
|
||||
|
||||
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
|
||||
// On permission denied, Edge API returns just an empty array []!
|
||||
if len(line) == 0 || line[0] == '[' {
|
||||
return fmt.Errorf("%w", ErrFaultyResponsePerhaps403)
|
||||
}
|
||||
var response Response[App]
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
return err
|
||||
|
|
@ -253,9 +238,7 @@ func (c *Client) handleErrorResponse(resp *http.Response, operation string) erro
|
|||
bodyBytes := []byte{}
|
||||
|
||||
if resp.Body != nil {
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ = io.ReadAll(resp.Body)
|
||||
messages = append(messages, string(bodyBytes))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ func TestCreateApp(t *testing.T) {
|
|||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -139,7 +139,7 @@ func TestShowApp(t *testing.T) {
|
|||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
|
@ -186,7 +186,7 @@ func TestShowApps(t *testing.T) {
|
|||
{"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}}
|
||||
`
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(response))
|
||||
w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -277,7 +277,7 @@ func TestUpdateApp(t *testing.T) {
|
|||
assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -407,3 +407,13 @@ func TestAPIError(t *testing.T) {
|
|||
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"))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,9 +138,7 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body - same as existing implementation
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ func TestUsernamePasswordProvider_Success(t *testing.T) {
|
|||
// Return token
|
||||
response := map[string]string{"token": "dynamic-token-456"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ 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"))
|
||||
w.Write([]byte("Invalid credentials"))
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ func TestUsernamePasswordProvider_TokenCaching(t *testing.T) {
|
|||
callCount++
|
||||
response := map[string]string{"token": "cached-token-789"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) {
|
|||
callCount++
|
||||
response := map[string]string{"token": "refreshed-token-999"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) {
|
|||
callCount++
|
||||
response := map[string]string{"token": "new-token-after-invalidation"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
|
|
@ -185,7 +185,7 @@ 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"))
|
||||
w.Write([]byte("invalid json response"))
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http"
|
||||
)
|
||||
|
||||
// CreateCloudlet creates a new cloudlet in the specified region
|
||||
|
|
@ -22,9 +22,7 @@ func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) er
|
|||
if err != nil {
|
||||
return fmt.Errorf("CreateCloudlet failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "CreateCloudlet")
|
||||
|
|
@ -51,9 +49,7 @@ func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, regi
|
|||
if err != nil {
|
||||
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
|
||||
|
|
@ -93,9 +89,7 @@ func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, reg
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("ShowCloudlets failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return nil, c.handleErrorResponse(resp, "ShowCloudlets")
|
||||
|
|
@ -129,9 +123,7 @@ func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, re
|
|||
if err != nil {
|
||||
return fmt.Errorf("DeleteCloudlet failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 404 is acceptable for delete operations (already deleted)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
|
|
@ -159,9 +151,7 @@ func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKe
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("GetCloudletManifest failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w",
|
||||
|
|
@ -199,9 +189,7 @@ func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey Cloud
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w",
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ func TestCreateCloudlet(t *testing.T) {
|
|||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ func TestShowCloudlet(t *testing.T) {
|
|||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
|
@ -187,7 +187,7 @@ func TestShowCloudlets(t *testing.T) {
|
|||
{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}}
|
||||
`
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(response))
|
||||
w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -312,7 +312,7 @@ func TestGetCloudletManifest(t *testing.T) {
|
|||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
|
@ -380,7 +380,7 @@ func TestGetCloudletResourceUsage(t *testing.T) {
|
|||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
|
|
|||
|
|
@ -60,74 +60,74 @@ const (
|
|||
|
||||
// AppInstance field constants for partial updates (based on EdgeXR API specification)
|
||||
const (
|
||||
AppInstFieldKey = "2"
|
||||
AppInstFieldKeyAppKey = "2.1"
|
||||
AppInstFieldKeyAppKeyOrganization = "2.1.1"
|
||||
AppInstFieldKeyAppKeyName = "2.1.2"
|
||||
AppInstFieldKeyAppKeyVersion = "2.1.3"
|
||||
AppInstFieldKeyClusterInstKey = "2.4"
|
||||
AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1"
|
||||
AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2"
|
||||
AppInstFieldKey = "2"
|
||||
AppInstFieldKeyAppKey = "2.1"
|
||||
AppInstFieldKeyAppKeyOrganization = "2.1.1"
|
||||
AppInstFieldKeyAppKeyName = "2.1.2"
|
||||
AppInstFieldKeyAppKeyVersion = "2.1.3"
|
||||
AppInstFieldKeyClusterInstKey = "2.4"
|
||||
AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1"
|
||||
AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKeyFederatedOrganization = "2.4.2.3"
|
||||
AppInstFieldKeyClusterInstKeyOrganization = "2.4.3"
|
||||
AppInstFieldCloudletLoc = "3"
|
||||
AppInstFieldCloudletLocLatitude = "3.1"
|
||||
AppInstFieldCloudletLocLongitude = "3.2"
|
||||
AppInstFieldCloudletLocHorizontalAccuracy = "3.3"
|
||||
AppInstFieldCloudletLocVerticalAccuracy = "3.4"
|
||||
AppInstFieldCloudletLocAltitude = "3.5"
|
||||
AppInstFieldCloudletLocCourse = "3.6"
|
||||
AppInstFieldCloudletLocSpeed = "3.7"
|
||||
AppInstFieldCloudletLocTimestamp = "3.8"
|
||||
AppInstFieldCloudletLocTimestampSeconds = "3.8.1"
|
||||
AppInstFieldCloudletLocTimestampNanos = "3.8.2"
|
||||
AppInstFieldUri = "4"
|
||||
AppInstFieldLiveness = "6"
|
||||
AppInstFieldMappedPorts = "9"
|
||||
AppInstFieldMappedPortsProto = "9.1"
|
||||
AppInstFieldMappedPortsInternalPort = "9.2"
|
||||
AppInstFieldMappedPortsPublicPort = "9.3"
|
||||
AppInstFieldMappedPortsFqdnPrefix = "9.5"
|
||||
AppInstFieldMappedPortsEndPort = "9.6"
|
||||
AppInstFieldMappedPortsTls = "9.7"
|
||||
AppInstFieldMappedPortsNginx = "9.8"
|
||||
AppInstFieldMappedPortsMaxPktSize = "9.9"
|
||||
AppInstFieldFlavor = "12"
|
||||
AppInstFieldFlavorName = "12.1"
|
||||
AppInstFieldState = "14"
|
||||
AppInstFieldErrors = "15"
|
||||
AppInstFieldCrmOverride = "16"
|
||||
AppInstFieldRuntimeInfo = "17"
|
||||
AppInstFieldRuntimeInfoContainerIds = "17.1"
|
||||
AppInstFieldCreatedAt = "21"
|
||||
AppInstFieldCreatedAtSeconds = "21.1"
|
||||
AppInstFieldCreatedAtNanos = "21.2"
|
||||
AppInstFieldAutoClusterIpAccess = "22"
|
||||
AppInstFieldRevision = "24"
|
||||
AppInstFieldForceUpdate = "25"
|
||||
AppInstFieldUpdateMultiple = "26"
|
||||
AppInstFieldConfigs = "27"
|
||||
AppInstFieldConfigsKind = "27.1"
|
||||
AppInstFieldConfigsConfig = "27.2"
|
||||
AppInstFieldHealthCheck = "29"
|
||||
AppInstFieldPowerState = "31"
|
||||
AppInstFieldExternalVolumeSize = "32"
|
||||
AppInstFieldAvailabilityZone = "33"
|
||||
AppInstFieldVmFlavor = "34"
|
||||
AppInstFieldOptRes = "35"
|
||||
AppInstFieldUpdatedAt = "36"
|
||||
AppInstFieldUpdatedAtSeconds = "36.1"
|
||||
AppInstFieldUpdatedAtNanos = "36.2"
|
||||
AppInstFieldRealClusterName = "37"
|
||||
AppInstFieldInternalPortToLbIp = "38"
|
||||
AppInstFieldInternalPortToLbIpKey = "38.1"
|
||||
AppInstFieldInternalPortToLbIpValue = "38.2"
|
||||
AppInstFieldDedicatedIp = "39"
|
||||
AppInstFieldUniqueId = "40"
|
||||
AppInstFieldDnsLabel = "41"
|
||||
AppInstFieldKeyClusterInstKeyOrganization = "2.4.3"
|
||||
AppInstFieldCloudletLoc = "3"
|
||||
AppInstFieldCloudletLocLatitude = "3.1"
|
||||
AppInstFieldCloudletLocLongitude = "3.2"
|
||||
AppInstFieldCloudletLocHorizontalAccuracy = "3.3"
|
||||
AppInstFieldCloudletLocVerticalAccuracy = "3.4"
|
||||
AppInstFieldCloudletLocAltitude = "3.5"
|
||||
AppInstFieldCloudletLocCourse = "3.6"
|
||||
AppInstFieldCloudletLocSpeed = "3.7"
|
||||
AppInstFieldCloudletLocTimestamp = "3.8"
|
||||
AppInstFieldCloudletLocTimestampSeconds = "3.8.1"
|
||||
AppInstFieldCloudletLocTimestampNanos = "3.8.2"
|
||||
AppInstFieldUri = "4"
|
||||
AppInstFieldLiveness = "6"
|
||||
AppInstFieldMappedPorts = "9"
|
||||
AppInstFieldMappedPortsProto = "9.1"
|
||||
AppInstFieldMappedPortsInternalPort = "9.2"
|
||||
AppInstFieldMappedPortsPublicPort = "9.3"
|
||||
AppInstFieldMappedPortsFqdnPrefix = "9.5"
|
||||
AppInstFieldMappedPortsEndPort = "9.6"
|
||||
AppInstFieldMappedPortsTls = "9.7"
|
||||
AppInstFieldMappedPortsNginx = "9.8"
|
||||
AppInstFieldMappedPortsMaxPktSize = "9.9"
|
||||
AppInstFieldFlavor = "12"
|
||||
AppInstFieldFlavorName = "12.1"
|
||||
AppInstFieldState = "14"
|
||||
AppInstFieldErrors = "15"
|
||||
AppInstFieldCrmOverride = "16"
|
||||
AppInstFieldRuntimeInfo = "17"
|
||||
AppInstFieldRuntimeInfoContainerIds = "17.1"
|
||||
AppInstFieldCreatedAt = "21"
|
||||
AppInstFieldCreatedAtSeconds = "21.1"
|
||||
AppInstFieldCreatedAtNanos = "21.2"
|
||||
AppInstFieldAutoClusterIpAccess = "22"
|
||||
AppInstFieldRevision = "24"
|
||||
AppInstFieldForceUpdate = "25"
|
||||
AppInstFieldUpdateMultiple = "26"
|
||||
AppInstFieldConfigs = "27"
|
||||
AppInstFieldConfigsKind = "27.1"
|
||||
AppInstFieldConfigsConfig = "27.2"
|
||||
AppInstFieldHealthCheck = "29"
|
||||
AppInstFieldPowerState = "31"
|
||||
AppInstFieldExternalVolumeSize = "32"
|
||||
AppInstFieldAvailabilityZone = "33"
|
||||
AppInstFieldVmFlavor = "34"
|
||||
AppInstFieldOptRes = "35"
|
||||
AppInstFieldUpdatedAt = "36"
|
||||
AppInstFieldUpdatedAtSeconds = "36.1"
|
||||
AppInstFieldUpdatedAtNanos = "36.2"
|
||||
AppInstFieldRealClusterName = "37"
|
||||
AppInstFieldInternalPortToLbIp = "38"
|
||||
AppInstFieldInternalPortToLbIpKey = "38.1"
|
||||
AppInstFieldInternalPortToLbIpValue = "38.2"
|
||||
AppInstFieldDedicatedIp = "39"
|
||||
AppInstFieldUniqueId = "40"
|
||||
AppInstFieldDnsLabel = "41"
|
||||
)
|
||||
|
||||
// Message interface for types that can provide error messages
|
||||
|
|
|
|||
|
|
@ -1,293 +0,0 @@
|
|||
// ABOUTME: Application instance lifecycle management APIs for EdgeXR Master Controller
|
||||
// ABOUTME: Provides typed methods for creating, querying, and deleting application instances
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "CreateAppInstance")
|
||||
}
|
||||
|
||||
// Parse streaming JSON response
|
||||
if _, err = parseStreamingResponse[AppInstance](resp); err != nil {
|
||||
return fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
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, appKey AppKey, 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 func() {
|
||||
_ = 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 appInstances, err = parseStreamingResponse[AppInstance](resp); 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, appKey AppKey, region string) ([]AppInstance, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
|
||||
|
||||
filter := AppInstanceFilter{
|
||||
AppInstance: AppInstance{Key: appInstKey, AppKey: appKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return nil, c.handleErrorResponse(resp, "ShowAppInstances")
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return []AppInstance{}, nil // Return empty slice for not found
|
||||
}
|
||||
|
||||
var appInstances []AppInstance
|
||||
if appInstances, err = parseStreamingResponse[AppInstance](resp); 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 func() {
|
||||
_ = 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 func() {
|
||||
_ = 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
|
||||
// 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"
|
||||
|
||||
input := DeleteAppInstanceInput{
|
||||
Region: region,
|
||||
}
|
||||
input.AppInst.Key = appInstKey
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteAppInstance failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = 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 parseStreamingResponse[T Message](resp *http.Response) ([]T, error) {
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return []T{}, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// todo finish check the responses, test them, and make a unify result, probably need
|
||||
// to update the response parameter to the message type e.g. App or AppInst
|
||||
isV2, err := isV2Response(bodyBytes)
|
||||
if err != nil {
|
||||
return []T{}, fmt.Errorf("failed to parse streaming response: %w", err)
|
||||
}
|
||||
|
||||
if isV2 {
|
||||
resultV2, err := parseStreamingResponseV2[T](resp.StatusCode, bodyBytes)
|
||||
if err != nil {
|
||||
return []T{}, err
|
||||
}
|
||||
return resultV2, nil
|
||||
}
|
||||
|
||||
resultV1, err := parseStreamingResponseV1[T](resp.StatusCode, bodyBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resultV1.IsSuccessful() {
|
||||
return []T{}, resultV1.Error()
|
||||
}
|
||||
|
||||
return resultV1.GetData(), nil
|
||||
}
|
||||
|
||||
func parseStreamingResponseV1[T Message](statusCode int, bodyBytes []byte) (Responses[T], error) {
|
||||
// Fall back to streaming format (v1 API format)
|
||||
var responses Responses[T]
|
||||
responses.StatusCode = statusCode
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(bodyBytes))
|
||||
for {
|
||||
var d Response[T]
|
||||
if err := decoder.Decode(&d); err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
break
|
||||
}
|
||||
return Responses[T]{}, fmt.Errorf("error in parsing json object into Message: %w", err)
|
||||
}
|
||||
|
||||
if d.Result.Message != "" && d.Result.Code != 0 {
|
||||
responses.StatusCode = d.Result.Code
|
||||
}
|
||||
|
||||
if strings.Contains(d.Data.GetMessage(), "CreateError") {
|
||||
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "CreateError"))
|
||||
}
|
||||
|
||||
if strings.Contains(d.Data.GetMessage(), "UpdateError") {
|
||||
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "UpdateError"))
|
||||
}
|
||||
|
||||
if strings.Contains(d.Data.GetMessage(), "DeleteError") {
|
||||
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "DeleteError"))
|
||||
}
|
||||
|
||||
responses.Responses = append(responses.Responses, d)
|
||||
}
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
func isV2Response(bodyBytes []byte) (bool, error) {
|
||||
if len(bodyBytes) == 0 {
|
||||
return false, fmt.Errorf("malformatted response body")
|
||||
}
|
||||
|
||||
return bodyBytes[0] == '[', nil
|
||||
}
|
||||
|
||||
func parseStreamingResponseV2[T Message](statusCode int, bodyBytes []byte) ([]T, error) {
|
||||
var result []T
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return result, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -1,527 +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 v2
|
||||
|
||||
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
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
name: "HTTP 200 with CreateError message",
|
||||
input: &NewAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
Flavor: Flavor{Name: "m4.small"},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"data":{"message":"Creating"}}
|
||||
{"data":{"message":"a service has been configured"}}
|
||||
{"data":{"message":"CreateError"}}
|
||||
{"data":{"message":"Deleting AppInst due to failure"}}
|
||||
{"data":{"message":"Deleted AppInst successfully"}}`,
|
||||
expectError: true,
|
||||
errorContains: "CreateError",
|
||||
},
|
||||
{
|
||||
name: "HTTP 200 with result error code",
|
||||
input: &NewAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
Flavor: Flavor{Name: "m4.small"},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"data":{"message":"Creating"}}
|
||||
{"data":{"message":"a service has been configured"}}
|
||||
{"data":{"message":"CreateError"}}
|
||||
{"data":{"message":"Deleting AppInst due to failure"}}
|
||||
{"data":{"message":"Deleted AppInst successfully"}}
|
||||
{"result":{"message":"Encountered failures: Create App Inst failed: deployments.apps is forbidden: User \"system:serviceaccount:edgexr:crm-telekomop-munich\" cannot create resource \"deployments\" in API group \"apps\" in the namespace \"gitea\"","code":400}}`,
|
||||
expectError: true,
|
||||
errorContains: "deployments.apps is forbidden",
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
if tt.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowAppInstance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appInstKey AppInstanceKey
|
||||
appKey AppKey
|
||||
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",
|
||||
},
|
||||
},
|
||||
appKey: AppKey{Name: "testapp"},
|
||||
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",
|
||||
},
|
||||
},
|
||||
appKey: AppKey{Name: "testapp"},
|
||||
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.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.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"}, AppKey{}, "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,213 +0,0 @@
|
|||
// ABOUTME: Application lifecycle management APIs for EdgeXR Master Controller
|
||||
// ABOUTME: Provides typed methods for creating, querying, and deleting applications
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/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 func() {
|
||||
_ = 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 func() {
|
||||
_ = 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 apps, err = parseStreamingResponse[App](resp); 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 func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return nil, c.handleErrorResponse(resp, "ShowApps")
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return []App{}, nil // Return empty slice for not found
|
||||
}
|
||||
|
||||
var apps []App
|
||||
if apps, err = parseStreamingResponse[App](resp); 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 func() {
|
||||
_ = 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"
|
||||
|
||||
input := DeleteAppInput{
|
||||
Region: region,
|
||||
}
|
||||
input.App.Key = appKey
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteApp failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = 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
|
||||
}
|
||||
|
||||
// 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 func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
bodyBytes, _ = io.ReadAll(resp.Body)
|
||||
messages = append(messages, string(bodyBytes))
|
||||
}
|
||||
|
||||
return &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Messages: messages,
|
||||
Body: bodyBytes,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,409 +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 v2
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
// ABOUTME: Authentication providers for EdgeXR Master Controller API
|
||||
// ABOUTME: Supports Bearer token authentication with pluggable provider interface
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthProvider interface for attaching authentication to requests
|
||||
type AuthProvider interface {
|
||||
// Attach adds authentication headers to the request
|
||||
Attach(ctx context.Context, req *http.Request) error
|
||||
}
|
||||
|
||||
// StaticTokenProvider implements Bearer token authentication with a fixed token
|
||||
type StaticTokenProvider struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
// NewStaticTokenProvider creates a new static token provider
|
||||
func NewStaticTokenProvider(token string) *StaticTokenProvider {
|
||||
return &StaticTokenProvider{Token: token}
|
||||
}
|
||||
|
||||
// Attach adds the Bearer token to the request Authorization header
|
||||
func (s *StaticTokenProvider) Attach(ctx context.Context, req *http.Request) error {
|
||||
if s.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.Token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsernamePasswordProvider implements dynamic token retrieval using username/password
|
||||
// This matches the existing client/client.go RetrieveToken implementation
|
||||
type UsernamePasswordProvider struct {
|
||||
BaseURL string
|
||||
Username string
|
||||
Password string
|
||||
HTTPClient *http.Client
|
||||
|
||||
// Token caching
|
||||
mu sync.RWMutex
|
||||
cachedToken string
|
||||
tokenExpiry time.Time
|
||||
}
|
||||
|
||||
// NewUsernamePasswordProvider creates a new username/password auth provider
|
||||
func NewUsernamePasswordProvider(baseURL, username, password string, httpClient *http.Client) *UsernamePasswordProvider {
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
|
||||
return &UsernamePasswordProvider{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Username: username,
|
||||
Password: password,
|
||||
HTTPClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Attach retrieves a token (with caching) and adds it to the Authorization header
|
||||
func (u *UsernamePasswordProvider) Attach(ctx context.Context, req *http.Request) error {
|
||||
token, err := u.getToken(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get token: %w", err)
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getToken retrieves a token, using cache if valid
|
||||
func (u *UsernamePasswordProvider) getToken(ctx context.Context) (string, error) {
|
||||
// Check cache first
|
||||
u.mu.RLock()
|
||||
if u.cachedToken != "" && time.Now().Before(u.tokenExpiry) {
|
||||
token := u.cachedToken
|
||||
u.mu.RUnlock()
|
||||
return token, nil
|
||||
}
|
||||
u.mu.RUnlock()
|
||||
|
||||
// Need to retrieve new token
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if u.cachedToken != "" && time.Now().Before(u.tokenExpiry) {
|
||||
return u.cachedToken, nil
|
||||
}
|
||||
|
||||
// Retrieve token using existing RetrieveToken logic
|
||||
token, err := u.retrieveToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Cache token with reasonable expiry (assume 1 hour, can be configurable)
|
||||
u.cachedToken = token
|
||||
u.tokenExpiry = time.Now().Add(1 * time.Hour)
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// retrieveToken implements the same logic as the existing client/client.go RetrieveToken method
|
||||
func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, error) {
|
||||
// Marshal credentials - same as existing implementation
|
||||
jsonData, err := json.Marshal(map[string]string{
|
||||
"username": u.Username,
|
||||
"password": u.Password,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create request - same as existing implementation
|
||||
loginURL := u.BaseURL + "/api/v1/login"
|
||||
request, err := http.NewRequestWithContext(ctx, "POST", loginURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Execute request
|
||||
resp, err := u.HTTPClient.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// Read response body - same as existing implementation
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error reading response body: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse JSON response - same as existing implementation
|
||||
var respData struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
err = json.Unmarshal(body, &respData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing JSON (status %d): %v", resp.StatusCode, err)
|
||||
}
|
||||
|
||||
return respData.Token, nil
|
||||
}
|
||||
|
||||
// InvalidateToken clears the cached token, forcing a new login on next request
|
||||
func (u *UsernamePasswordProvider) InvalidateToken() {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
u.cachedToken = ""
|
||||
u.tokenExpiry = time.Time{}
|
||||
}
|
||||
|
||||
// NoAuthProvider implements no authentication (for testing or public endpoints)
|
||||
type NoAuthProvider struct{}
|
||||
|
||||
// NewNoAuthProvider creates a new no-auth provider
|
||||
func NewNoAuthProvider() *NoAuthProvider {
|
||||
return &NoAuthProvider{}
|
||||
}
|
||||
|
||||
// Attach does nothing (no authentication)
|
||||
func (n *NoAuthProvider) Attach(ctx context.Context, req *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -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 v2
|
||||
|
||||
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 v2
|
||||
|
||||
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,283 +0,0 @@
|
|||
// ABOUTME: Cloudlet management APIs for EdgeXR Master Controller
|
||||
// ABOUTME: Provides typed methods for creating, querying, and managing edge cloudlets
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/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 func() {
|
||||
_ = 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 func() {
|
||||
_ = 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 func() {
|
||||
_ = 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 func() {
|
||||
_ = 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 func() {
|
||||
_ = 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 func() {
|
||||
_ = 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 v2
|
||||
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,421 +0,0 @@
|
|||
// ABOUTME: Core type definitions for EdgeXR Master Controller SDK
|
||||
// ABOUTME: These types are based on the swagger API specification and existing client patterns
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// App field constants for partial updates (based on EdgeXR API specification)
|
||||
const (
|
||||
AppFieldKey = "2"
|
||||
AppFieldKeyOrganization = "2.1"
|
||||
AppFieldKeyName = "2.2"
|
||||
AppFieldKeyVersion = "2.3"
|
||||
AppFieldImagePath = "4"
|
||||
AppFieldImageType = "5"
|
||||
AppFieldAccessPorts = "7"
|
||||
AppFieldDefaultFlavor = "9"
|
||||
AppFieldDefaultFlavorName = "9.1"
|
||||
AppFieldAuthPublicKey = "12"
|
||||
AppFieldCommand = "13"
|
||||
AppFieldAnnotations = "14"
|
||||
AppFieldDeployment = "15"
|
||||
AppFieldDeploymentManifest = "16"
|
||||
AppFieldDeploymentGenerator = "17"
|
||||
AppFieldAndroidPackageName = "18"
|
||||
AppFieldDelOpt = "20"
|
||||
AppFieldConfigs = "21"
|
||||
AppFieldConfigsKind = "21.1"
|
||||
AppFieldConfigsConfig = "21.2"
|
||||
AppFieldScaleWithCluster = "22"
|
||||
AppFieldInternalPorts = "23"
|
||||
AppFieldRevision = "24"
|
||||
AppFieldOfficialFqdn = "25"
|
||||
AppFieldMd5Sum = "26"
|
||||
AppFieldAutoProvPolicy = "28"
|
||||
AppFieldAccessType = "29"
|
||||
AppFieldDeletePrepare = "31"
|
||||
AppFieldAutoProvPolicies = "32"
|
||||
AppFieldTemplateDelimiter = "33"
|
||||
AppFieldSkipHcPorts = "34"
|
||||
AppFieldCreatedAt = "35"
|
||||
AppFieldCreatedAtSeconds = "35.1"
|
||||
AppFieldCreatedAtNanos = "35.2"
|
||||
AppFieldUpdatedAt = "36"
|
||||
AppFieldUpdatedAtSeconds = "36.1"
|
||||
AppFieldUpdatedAtNanos = "36.2"
|
||||
AppFieldTrusted = "37"
|
||||
AppFieldRequiredOutboundConnections = "38"
|
||||
AppFieldAllowServerless = "39"
|
||||
AppFieldServerlessConfig = "40"
|
||||
AppFieldVmAppOsType = "41"
|
||||
AppFieldAlertPolicies = "42"
|
||||
AppFieldQosSessionProfile = "43"
|
||||
AppFieldQosSessionDuration = "44"
|
||||
)
|
||||
|
||||
// AppInstance field constants for partial updates (based on EdgeXR API specification)
|
||||
const (
|
||||
AppInstFieldKey = "2"
|
||||
AppInstFieldKeyAppKey = "2.1"
|
||||
AppInstFieldKeyAppKeyOrganization = "2.1.1"
|
||||
AppInstFieldKeyAppKeyName = "2.1.2"
|
||||
AppInstFieldKeyAppKeyVersion = "2.1.3"
|
||||
AppInstFieldKeyClusterInstKey = "2.4"
|
||||
AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1"
|
||||
AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKeyFederatedOrganization = "2.4.2.3"
|
||||
AppInstFieldKeyClusterInstKeyOrganization = "2.4.3"
|
||||
AppInstFieldCloudletLoc = "3"
|
||||
AppInstFieldCloudletLocLatitude = "3.1"
|
||||
AppInstFieldCloudletLocLongitude = "3.2"
|
||||
AppInstFieldCloudletLocHorizontalAccuracy = "3.3"
|
||||
AppInstFieldCloudletLocVerticalAccuracy = "3.4"
|
||||
AppInstFieldCloudletLocAltitude = "3.5"
|
||||
AppInstFieldCloudletLocCourse = "3.6"
|
||||
AppInstFieldCloudletLocSpeed = "3.7"
|
||||
AppInstFieldCloudletLocTimestamp = "3.8"
|
||||
AppInstFieldCloudletLocTimestampSeconds = "3.8.1"
|
||||
AppInstFieldCloudletLocTimestampNanos = "3.8.2"
|
||||
AppInstFieldUri = "4"
|
||||
AppInstFieldLiveness = "6"
|
||||
AppInstFieldMappedPorts = "9"
|
||||
AppInstFieldMappedPortsProto = "9.1"
|
||||
AppInstFieldMappedPortsInternalPort = "9.2"
|
||||
AppInstFieldMappedPortsPublicPort = "9.3"
|
||||
AppInstFieldMappedPortsFqdnPrefix = "9.5"
|
||||
AppInstFieldMappedPortsEndPort = "9.6"
|
||||
AppInstFieldMappedPortsTls = "9.7"
|
||||
AppInstFieldMappedPortsNginx = "9.8"
|
||||
AppInstFieldMappedPortsMaxPktSize = "9.9"
|
||||
AppInstFieldFlavor = "12"
|
||||
AppInstFieldFlavorName = "12.1"
|
||||
AppInstFieldState = "14"
|
||||
AppInstFieldErrors = "15"
|
||||
AppInstFieldCrmOverride = "16"
|
||||
AppInstFieldRuntimeInfo = "17"
|
||||
AppInstFieldRuntimeInfoContainerIds = "17.1"
|
||||
AppInstFieldCreatedAt = "21"
|
||||
AppInstFieldCreatedAtSeconds = "21.1"
|
||||
AppInstFieldCreatedAtNanos = "21.2"
|
||||
AppInstFieldAutoClusterIpAccess = "22"
|
||||
AppInstFieldRevision = "24"
|
||||
AppInstFieldForceUpdate = "25"
|
||||
AppInstFieldUpdateMultiple = "26"
|
||||
AppInstFieldConfigs = "27"
|
||||
AppInstFieldConfigsKind = "27.1"
|
||||
AppInstFieldConfigsConfig = "27.2"
|
||||
AppInstFieldHealthCheck = "29"
|
||||
AppInstFieldPowerState = "31"
|
||||
AppInstFieldExternalVolumeSize = "32"
|
||||
AppInstFieldAvailabilityZone = "33"
|
||||
AppInstFieldVmFlavor = "34"
|
||||
AppInstFieldOptRes = "35"
|
||||
AppInstFieldUpdatedAt = "36"
|
||||
AppInstFieldUpdatedAtSeconds = "36.1"
|
||||
AppInstFieldUpdatedAtNanos = "36.2"
|
||||
AppInstFieldRealClusterName = "37"
|
||||
AppInstFieldInternalPortToLbIp = "38"
|
||||
AppInstFieldInternalPortToLbIpKey = "38.1"
|
||||
AppInstFieldInternalPortToLbIpValue = "38.2"
|
||||
AppInstFieldDedicatedIp = "39"
|
||||
AppInstFieldUniqueId = "40"
|
||||
AppInstFieldDnsLabel = "41"
|
||||
)
|
||||
|
||||
// Message interface for types that can provide error messages
|
||||
type Message interface {
|
||||
GetMessage() string
|
||||
}
|
||||
|
||||
// Base message type for API responses
|
||||
type msg struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (m msg) GetMessage() string {
|
||||
return m.Message
|
||||
}
|
||||
|
||||
// AppKey uniquely identifies an application
|
||||
type AppKey struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// CloudletKey uniquely identifies a cloudlet
|
||||
type CloudletKey struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AppInstanceKey uniquely identifies an application instance
|
||||
type AppInstanceKey struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name"`
|
||||
CloudletKey CloudletKey `json:"cloudlet_key"`
|
||||
}
|
||||
|
||||
// Flavor defines resource allocation for instances
|
||||
type Flavor struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SecurityRule defines network access rules
|
||||
type SecurityRule struct {
|
||||
PortRangeMax int `json:"port_range_max"`
|
||||
PortRangeMin int `json:"port_range_min"`
|
||||
Protocol string `json:"protocol"`
|
||||
RemoteCIDR string `json:"remote_cidr"`
|
||||
}
|
||||
|
||||
// App represents an application definition
|
||||
type App struct {
|
||||
msg `json:",inline"`
|
||||
Key AppKey `json:"key"`
|
||||
Deployment string `json:"deployment,omitempty"`
|
||||
ImageType string `json:"image_type,omitempty"`
|
||||
ImagePath string `json:"image_path,omitempty"`
|
||||
AccessPorts string `json:"access_ports,omitempty"`
|
||||
AllowServerless bool `json:"allow_serverless,omitempty"`
|
||||
DefaultFlavor Flavor `json:"defaultFlavor,omitempty"`
|
||||
ServerlessConfig interface{} `json:"serverless_config,omitempty"`
|
||||
DeploymentGenerator string `json:"deployment_generator,omitempty"`
|
||||
DeploymentManifest string `json:"deployment_manifest,omitempty"`
|
||||
RequiredOutboundConnections []SecurityRule `json:"required_outbound_connections"`
|
||||
GlobalID string `json:"global_id,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// AppInstance represents a deployed application instance
|
||||
type AppInstance struct {
|
||||
msg `json:",inline"`
|
||||
Key AppInstanceKey `json:"key"`
|
||||
AppKey AppKey `json:"app_key,omitempty"`
|
||||
CloudletLoc CloudletLoc `json:"cloudlet_loc,omitempty"`
|
||||
Flavor Flavor `json:"flavor,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
IngressURL string `json:"ingress_url,omitempty"`
|
||||
UniqueID string `json:"unique_id,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
PowerState string `json:"power_state,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// Cloudlet represents edge infrastructure
|
||||
type Cloudlet struct {
|
||||
msg `json:",inline"`
|
||||
Key CloudletKey `json:"key"`
|
||||
Location Location `json:"location"`
|
||||
IpSupport string `json:"ip_support,omitempty"`
|
||||
NumDynamicIps int32 `json:"num_dynamic_ips,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Flavor Flavor `json:"flavor,omitempty"`
|
||||
PhysicalName string `json:"physical_name,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
NotifySrvAddr string `json:"notify_srv_addr,omitempty"`
|
||||
}
|
||||
|
||||
// Location represents geographical coordinates
|
||||
type Location struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
// CloudletLoc represents geographical coordinates for cloudlets
|
||||
type CloudletLoc struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
// Input types for API operations
|
||||
|
||||
// NewAppInput represents input for creating an application
|
||||
type NewAppInput struct {
|
||||
Region string `json:"region"`
|
||||
App App `json:"app"`
|
||||
}
|
||||
|
||||
// NewAppInstanceInput represents input for creating an app instance
|
||||
type NewAppInstanceInput struct {
|
||||
Region string `json:"region"`
|
||||
AppInst AppInstance `json:"appinst"`
|
||||
}
|
||||
|
||||
// NewCloudletInput represents input for creating a cloudlet
|
||||
type NewCloudletInput struct {
|
||||
Region string `json:"region"`
|
||||
Cloudlet Cloudlet `json:"cloudlet"`
|
||||
}
|
||||
|
||||
// UpdateAppInput represents input for updating an application
|
||||
type UpdateAppInput struct {
|
||||
Region string `json:"region"`
|
||||
App App `json:"app"`
|
||||
}
|
||||
|
||||
// UpdateAppInstanceInput represents input for updating an app instance
|
||||
type UpdateAppInstanceInput struct {
|
||||
Region string `json:"region"`
|
||||
AppInst AppInstance `json:"appinst"`
|
||||
}
|
||||
|
||||
// DeleteAppInput represents input for deleting an application
|
||||
type DeleteAppInput struct {
|
||||
Region string `json:"region"`
|
||||
App struct {
|
||||
Key AppKey `json:"key"`
|
||||
} `json:"app"`
|
||||
}
|
||||
|
||||
// DeleteAppInstanceInput represents input for deleting an app instance
|
||||
type DeleteAppInstanceInput struct {
|
||||
Region string `json:"region"`
|
||||
AppInst struct {
|
||||
Key AppInstanceKey `json:"key"`
|
||||
} `json:"appinst"`
|
||||
}
|
||||
|
||||
// Response wrapper types
|
||||
|
||||
// Response wraps a single API response
|
||||
type Response[T Message] struct {
|
||||
ResultResponse `json:",inline"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
func (res *Response[T]) HasData() bool {
|
||||
return !res.IsMessage()
|
||||
}
|
||||
|
||||
func (res *Response[T]) IsMessage() bool {
|
||||
return res.Data.GetMessage() != ""
|
||||
}
|
||||
|
||||
// ResultResponse represents an API result with error code
|
||||
type ResultResponse struct {
|
||||
Result struct {
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
func (r *ResultResponse) IsError() bool {
|
||||
return r.Result.Code >= 400
|
||||
}
|
||||
|
||||
func (r *ResultResponse) GetMessage() string {
|
||||
return r.Result.Message
|
||||
}
|
||||
|
||||
func (r *ResultResponse) GetCode() int {
|
||||
return r.Result.Code
|
||||
}
|
||||
|
||||
// Responses wraps multiple API responses with metadata
|
||||
type Responses[T Message] struct {
|
||||
Responses []Response[T] `json:"responses,omitempty"`
|
||||
StatusCode int `json:"-"`
|
||||
Errors []error `json:"-"`
|
||||
}
|
||||
|
||||
func (r *Responses[T]) GetData() []T {
|
||||
var data []T
|
||||
for _, v := range r.Responses {
|
||||
if v.HasData() {
|
||||
data = append(data, v.Data)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (r *Responses[T]) GetMessages() []string {
|
||||
var messages []string
|
||||
for _, v := range r.Responses {
|
||||
if v.IsMessage() {
|
||||
messages = append(messages, v.Data.GetMessage())
|
||||
}
|
||||
if v.Result.Message != "" {
|
||||
messages = append(messages, v.Result.Message)
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func (r *Responses[T]) IsSuccessful() bool {
|
||||
return len(r.Errors) == 0 && (r.StatusCode >= 200 && r.StatusCode < 400)
|
||||
}
|
||||
|
||||
func (r *Responses[T]) Error() error {
|
||||
if r.IsSuccessful() {
|
||||
return nil
|
||||
}
|
||||
return &APIError{
|
||||
StatusCode: r.StatusCode,
|
||||
Messages: r.GetMessages(),
|
||||
}
|
||||
}
|
||||
|
||||
// APIError represents an API error with details
|
||||
type APIError struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Messages []string `json:"messages,omitempty"`
|
||||
Body []byte `json:"-"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
jsonErr, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("API error: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("API error: %s", jsonErr)
|
||||
}
|
||||
|
||||
// Filter types for querying
|
||||
|
||||
// AppFilter represents filters for app queries
|
||||
type AppFilter struct {
|
||||
App App `json:"app"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// AppInstanceFilter represents filters for app instance queries
|
||||
type AppInstanceFilter struct {
|
||||
AppInstance AppInstance `json:"appinst"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// CloudletFilter represents filters for cloudlet queries
|
||||
type CloudletFilter struct {
|
||||
Cloudlet Cloudlet `json:"cloudlet"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// CloudletManifest represents cloudlet deployment manifest
|
||||
type CloudletManifest struct {
|
||||
Manifest string `json:"manifest"`
|
||||
LastModified time.Time `json:"last_modified,omitempty"`
|
||||
}
|
||||
|
||||
// CloudletResourceUsage represents cloudlet resource utilization
|
||||
type CloudletResourceUsage struct {
|
||||
CloudletKey CloudletKey `json:"cloudlet_key"`
|
||||
Region string `json:"region"`
|
||||
Usage map[string]interface{} `json:"usage"`
|
||||
}
|
||||
|
||||
type ErrorMessage struct {
|
||||
Message string
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
# Is there a swagger file for the new EdgeConnect API?
|
||||
# How does it differ from the EdgeXR API?
|
||||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "edge-app-demo" # name could be used for appName
|
||||
appVersion: "1"
|
||||
organization: "edp2-orca"
|
||||
spec:
|
||||
# dockerApp: # Docker is OBSOLETE
|
||||
# appVersion: "1.0.0"
|
||||
# manifestFile: "./docker-compose.yaml"
|
||||
# image: "https://registry-1.docker.io/library/nginx:latest"
|
||||
k8sApp:
|
||||
manifestFile: "./k8s-deployment.yaml"
|
||||
infraTemplate:
|
||||
- region: "US"
|
||||
cloudletOrg: "TelekomOp"
|
||||
cloudletName: "gardener-shepherd-test"
|
||||
flavorName: "defualt"
|
||||
network:
|
||||
outboundConnections:
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 80
|
||||
portRangeMax: 80
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 443
|
||||
portRangeMax: 443
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
|
|
@ -18,7 +18,6 @@ apiVersion: apps/v1
|
|||
kind: Deployment
|
||||
metadata:
|
||||
name: edgeconnect-coder-deployment
|
||||
#namespace: gitea
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
|
|
@ -33,7 +32,7 @@ spec:
|
|||
volumes:
|
||||
containers:
|
||||
- name: edgeconnect-coder
|
||||
image: edp.buildth.ing/devfw-cicd/fibonacci_pipeline:edge_platform_demo
|
||||
image: nginx:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -24,20 +24,20 @@ func main() {
|
|||
username := getEnvOrDefault("EDGEXR_USERNAME", "")
|
||||
password := getEnvOrDefault("EDGEXR_PASSWORD", "")
|
||||
|
||||
var client *v2.Client
|
||||
var client *edgeconnect.Client
|
||||
|
||||
if token != "" {
|
||||
fmt.Println("🔐 Using Bearer token authentication")
|
||||
client = v2.NewClient(baseURL,
|
||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)),
|
||||
v2.WithLogger(log.Default()),
|
||||
client = edgeconnect.NewClient(baseURL,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)),
|
||||
edgeconnect.WithLogger(log.Default()),
|
||||
)
|
||||
} else if username != "" && password != "" {
|
||||
fmt.Println("🔐 Using username/password authentication")
|
||||
client = v2.NewClientWithCredentials(baseURL, username, password,
|
||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
v2.WithLogger(log.Default()),
|
||||
client = edgeconnect.NewClientWithCredentials(baseURL, username, password,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect.WithLogger(log.Default()),
|
||||
)
|
||||
} else {
|
||||
log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD")
|
||||
|
|
@ -85,15 +85,15 @@ type WorkflowConfig struct {
|
|||
FlavorName string
|
||||
}
|
||||
|
||||
func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config WorkflowConfig) error {
|
||||
func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config WorkflowConfig) error {
|
||||
fmt.Println("═══ Phase 1: Application Management ═══")
|
||||
|
||||
// 1. Create Application
|
||||
fmt.Println("\n1️⃣ Creating application...")
|
||||
app := &v2.NewAppInput{
|
||||
app := &edgeconnect.NewAppInput{
|
||||
Region: config.Region,
|
||||
App: v2.App{
|
||||
Key: v2.AppKey{
|
||||
App: edgeconnect.App{
|
||||
Key: edgeconnect.AppKey{
|
||||
Organization: config.Organization,
|
||||
Name: config.AppName,
|
||||
Version: config.AppVersion,
|
||||
|
|
@ -101,10 +101,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow
|
|||
Deployment: "kubernetes",
|
||||
ImageType: "ImageTypeDocker", // field is ignored
|
||||
ImagePath: "https://registry-1.docker.io/library/nginx:latest", // must be set. Even for kubernetes
|
||||
DefaultFlavor: v2.Flavor{Name: config.FlavorName},
|
||||
DefaultFlavor: edgeconnect.Flavor{Name: config.FlavorName},
|
||||
ServerlessConfig: struct{}{}, // must be set
|
||||
AllowServerless: true, // must be set to true for kubernetes
|
||||
RequiredOutboundConnections: []v2.SecurityRule{
|
||||
RequiredOutboundConnections: []edgeconnect.SecurityRule{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
|
|
@ -128,7 +128,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow
|
|||
|
||||
// 2. Show Application Details
|
||||
fmt.Println("\n2️⃣ Querying application details...")
|
||||
appKey := v2.AppKey{
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: config.Organization,
|
||||
Name: config.AppName,
|
||||
Version: config.AppVersion,
|
||||
|
|
@ -146,7 +146,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow
|
|||
|
||||
// 3. List Applications in Organization
|
||||
fmt.Println("\n3️⃣ Listing applications in organization...")
|
||||
filter := v2.AppKey{Organization: config.Organization}
|
||||
filter := edgeconnect.AppKey{Organization: config.Organization}
|
||||
apps, err := c.ShowApps(ctx, filter, config.Region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list apps: %w", err)
|
||||
|
|
@ -160,19 +160,19 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow
|
|||
|
||||
// 4. Create Application Instance
|
||||
fmt.Println("\n4️⃣ Creating application instance...")
|
||||
instance := &v2.NewAppInstanceInput{
|
||||
instance := &edgeconnect.NewAppInstanceInput{
|
||||
Region: config.Region,
|
||||
AppInst: v2.AppInstance{
|
||||
Key: v2.AppInstanceKey{
|
||||
AppInst: edgeconnect.AppInstance{
|
||||
Key: edgeconnect.AppInstanceKey{
|
||||
Organization: config.Organization,
|
||||
Name: config.InstanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: config.CloudletOrg,
|
||||
Name: config.CloudletName,
|
||||
},
|
||||
},
|
||||
AppKey: appKey,
|
||||
Flavor: v2.Flavor{Name: config.FlavorName},
|
||||
Flavor: edgeconnect.Flavor{Name: config.FlavorName},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -184,16 +184,16 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow
|
|||
|
||||
// 5. Wait for Application Instance to be Ready
|
||||
fmt.Println("\n5️⃣ Waiting for application instance to be ready...")
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: config.Organization,
|
||||
Name: config.InstanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: config.CloudletOrg,
|
||||
Name: config.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, v2.AppKey{}, config.Region, 5*time.Minute)
|
||||
instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, config.Region, 5*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to wait for instance ready: %w", err)
|
||||
}
|
||||
|
|
@ -207,7 +207,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow
|
|||
|
||||
// 6. List Application Instances
|
||||
fmt.Println("\n6️⃣ Listing application instances...")
|
||||
instances, err := c.ShowAppInstances(ctx, v2.AppInstanceKey{Organization: config.Organization}, v2.AppKey{}, config.Region)
|
||||
instances, err := c.ShowAppInstances(ctx, edgeconnect.AppInstanceKey{Organization: config.Organization}, config.Region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list app instances: %w", err)
|
||||
}
|
||||
|
|
@ -228,7 +228,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow
|
|||
|
||||
// 8. Show Cloudlet Details
|
||||
fmt.Println("\n8️⃣ Querying cloudlet information...")
|
||||
cloudletKey := v2.CloudletKey{
|
||||
cloudletKey := edgeconnect.CloudletKey{
|
||||
Organization: config.CloudletOrg,
|
||||
Name: config.CloudletName,
|
||||
}
|
||||
|
|
@ -287,7 +287,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow
|
|||
// 13. Verify Cleanup
|
||||
fmt.Println("\n1️⃣3️⃣ Verifying cleanup...")
|
||||
_, err = c.ShowApp(ctx, appKey, config.Region)
|
||||
if err != nil && fmt.Sprintf("%v", err) == v2.ErrResourceNotFound.Error() {
|
||||
if err != nil && fmt.Sprintf("%v", err) == edgeconnect.ErrResourceNotFound.Error() {
|
||||
fmt.Printf("✅ Cleanup verified - app no longer exists\n")
|
||||
} else if err != nil {
|
||||
fmt.Printf("✅ Cleanup appears successful (verification returned: %v)\n", err)
|
||||
|
|
@ -306,7 +306,7 @@ func getEnvOrDefault(key, defaultValue string) string {
|
|||
}
|
||||
|
||||
// waitForInstanceReady polls the instance status until it's no longer "Creating" or timeout
|
||||
func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string, timeout time.Duration) (v2.AppInstance, error) {
|
||||
func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKey edgeconnect.AppInstanceKey, region string, timeout time.Duration) (edgeconnect.AppInstance, error) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -318,10 +318,10 @@ func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppI
|
|||
for {
|
||||
select {
|
||||
case <-timeoutCtx.Done():
|
||||
return v2.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout)
|
||||
return edgeconnect.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout)
|
||||
|
||||
case <-ticker.C:
|
||||
instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, appKey, region)
|
||||
instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, region)
|
||||
if err != nil {
|
||||
// Log error but continue polling
|
||||
fmt.Printf(" ⚠️ Error checking instance state: %v\n", err)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -24,22 +24,22 @@ func main() {
|
|||
username := getEnvOrDefault("EDGEXR_USERNAME", "")
|
||||
password := getEnvOrDefault("EDGEXR_PASSWORD", "")
|
||||
|
||||
var edgeClient *v2.Client
|
||||
var edgeClient *edgeconnect.Client
|
||||
|
||||
if token != "" {
|
||||
// Use static token authentication
|
||||
fmt.Println("🔐 Using Bearer token authentication")
|
||||
edgeClient = v2.NewClient(baseURL,
|
||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)),
|
||||
v2.WithLogger(log.Default()),
|
||||
edgeClient = edgeconnect.NewClient(baseURL,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)),
|
||||
edgeconnect.WithLogger(log.Default()),
|
||||
)
|
||||
} else if username != "" && password != "" {
|
||||
// Use username/password authentication (matches existing client pattern)
|
||||
fmt.Println("🔐 Using username/password authentication")
|
||||
edgeClient = v2.NewClientWithCredentials(baseURL, username, password,
|
||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
v2.WithLogger(log.Default()),
|
||||
edgeClient = edgeconnect.NewClientWithCredentials(baseURL, username, password,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect.WithLogger(log.Default()),
|
||||
)
|
||||
} else {
|
||||
log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD")
|
||||
|
|
@ -48,10 +48,10 @@ func main() {
|
|||
ctx := context.Background()
|
||||
|
||||
// Example application to deploy
|
||||
app := &v2.NewAppInput{
|
||||
app := &edgeconnect.NewAppInput{
|
||||
Region: "EU",
|
||||
App: v2.App{
|
||||
Key: v2.AppKey{
|
||||
App: edgeconnect.App{
|
||||
Key: edgeconnect.AppKey{
|
||||
Organization: "edp2",
|
||||
Name: "my-edge-app",
|
||||
Version: "1.0.0",
|
||||
|
|
@ -59,7 +59,7 @@ func main() {
|
|||
Deployment: "docker",
|
||||
ImageType: "ImageTypeDocker",
|
||||
ImagePath: "https://registry-1.docker.io/library/nginx:latest",
|
||||
DefaultFlavor: v2.Flavor{Name: "EU.small"},
|
||||
DefaultFlavor: edgeconnect.Flavor{Name: "EU.small"},
|
||||
ServerlessConfig: struct{}{},
|
||||
AllowServerless: false,
|
||||
},
|
||||
|
|
@ -73,7 +73,7 @@ func main() {
|
|||
fmt.Println("✅ SDK example completed successfully!")
|
||||
}
|
||||
|
||||
func demonstrateAppLifecycle(ctx context.Context, edgeClient *v2.Client, input *v2.NewAppInput) error {
|
||||
func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client, input *edgeconnect.NewAppInput) error {
|
||||
appKey := input.App.Key
|
||||
region := input.Region
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *v2.Client, input *
|
|||
|
||||
// Step 3: List applications in the organization
|
||||
fmt.Println("\n3. Listing applications...")
|
||||
filter := v2.AppKey{Organization: appKey.Organization}
|
||||
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)
|
||||
|
|
@ -116,7 +116,7 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *v2.Client, input *
|
|||
fmt.Println("\n5. Verifying deletion...")
|
||||
_, err = edgeClient.ShowApp(ctx, appKey, region)
|
||||
if err != nil {
|
||||
if strings.Contains(fmt.Sprintf("%v", err), v2.ErrResourceNotFound.Error()) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
# Is there a swagger file for the new EdgeConnect API?
|
||||
# How does it differ from the EdgeXR API?
|
||||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "forgejo-runner-orca" # name could be used for appName
|
||||
appVersion: "1"
|
||||
organization: "edp2-orca"
|
||||
spec:
|
||||
# dockerApp: # Docker is OBSOLETE
|
||||
# appVersion: "1.0.0"
|
||||
# manifestFile: "./docker-compose.yaml"
|
||||
# image: "https://registry-1.docker.io/library/nginx:latest"
|
||||
k8sApp:
|
||||
manifestFile: "./forgejo-runner-deployment.yaml"
|
||||
infraTemplate:
|
||||
- region: "US"
|
||||
cloudletOrg: "TelekomOp"
|
||||
cloudletName: "gardener-shepherd-test"
|
||||
flavorName: "defualt"
|
||||
network:
|
||||
outboundConnections:
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 80
|
||||
portRangeMax: 80
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 443
|
||||
portRangeMax: 443
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
# Is there a swagger file for the new EdgeConnect API?
|
||||
# How does it differ from the EdgeXR API?
|
||||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "edge-ubuntu-buildkit" # name could be used for appName
|
||||
appVersion: "1.0.0"
|
||||
organization: "edp2"
|
||||
spec:
|
||||
# dockerApp: # Docker is OBSOLETE
|
||||
# appVersion: "1.0.0"
|
||||
# manifestFile: "./docker-compose.yaml"
|
||||
# image: "https://registry-1.docker.io/library/nginx:latest"
|
||||
k8sApp:
|
||||
manifestFile: "./k8s-deployment.yaml"
|
||||
infraTemplate:
|
||||
- region: "EU"
|
||||
cloudletOrg: "TelekomOP"
|
||||
cloudletName: "Munich"
|
||||
flavorName: "EU.small"
|
||||
network:
|
||||
outboundConnections:
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 80
|
||||
portRangeMax: 80
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 443
|
||||
portRangeMax: 443
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
# Is there a swagger file for the new EdgeConnect API?
|
||||
# How does it differ from the EdgeXR API?
|
||||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "edge-ubuntu-buildkit" # name could be used for appName
|
||||
appVersion: "1"
|
||||
organization: "edp2-orca"
|
||||
spec:
|
||||
# dockerApp: # Docker is OBSOLETE
|
||||
# appVersion: "1.0.0"
|
||||
# manifestFile: "./docker-compose.yaml"
|
||||
# image: "https://registry-1.docker.io/library/nginx:latest"
|
||||
k8sApp:
|
||||
manifestFile: "./k8s-deployment.yaml"
|
||||
infraTemplate:
|
||||
- region: "US"
|
||||
cloudletOrg: "TelekomOp"
|
||||
cloudletName: "gardener-shepherd-test"
|
||||
flavorName: "defualt"
|
||||
network:
|
||||
outboundConnections:
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 80
|
||||
portRangeMax: 80
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 443
|
||||
portRangeMax: 443
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
# Add remote buildx builder:
|
||||
# docker buildx create --use --name sidecar tcp://127.0.0.1:1234
|
||||
|
||||
# Run build:
|
||||
# docker buildx build .
|
||||
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ubuntu-runner
|
||||
labels:
|
||||
run: ubuntu-runner
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
ports:
|
||||
- name: tcp80
|
||||
protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
selector:
|
||||
run: ubuntu-runner
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: ubuntu-runner
|
||||
name: ubuntu-runner
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ubuntu-runner
|
||||
strategy: {}
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: ubuntu-runner
|
||||
annotations:
|
||||
container.apparmor.security.beta.kubernetes.io/buildkitd: unconfined
|
||||
spec:
|
||||
containers:
|
||||
- name: ubuntu
|
||||
image: edp.buildth.ing/devfw-cicd/catthehacker/ubuntu:act-22.04-amd64
|
||||
command:
|
||||
- sleep
|
||||
- 7d
|
||||
- args:
|
||||
- --allow-insecure-entitlement=network.host
|
||||
- --oci-worker-no-process-sandbox
|
||||
- --addr
|
||||
- tcp://127.0.0.1:1234
|
||||
image: moby/buildkit:v0.25.1-rootless
|
||||
imagePullPolicy: IfNotPresent
|
||||
name: buildkitd
|
||||
|
|
@ -98,12 +98,10 @@ func NewTransport(opts RetryOptions, auth AuthProvider, logger Logger) *Transpor
|
|||
// 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
|
||||
var jsonData []byte
|
||||
|
||||
// Marshal request body if provided
|
||||
if body != nil {
|
||||
var err error
|
||||
jsonData, err = json.Marshal(body)
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
|
@ -129,16 +127,8 @@ func (t *Transport) Call(ctx context.Context, method, url string, body interface
|
|||
|
||||
// Log request
|
||||
if t.logger != nil {
|
||||
t.logger.Printf("=== HTTP REQUEST ===")
|
||||
t.logger.Printf("%s %s", method, url)
|
||||
if len(jsonData) > 0 {
|
||||
var prettyJSON bytes.Buffer
|
||||
if err := json.Indent(&prettyJSON, jsonData, "", " "); err == nil {
|
||||
t.logger.Printf("Request Body:\n%s", prettyJSON.String())
|
||||
} else {
|
||||
t.logger.Printf("Request Body: %s", string(jsonData))
|
||||
}
|
||||
}
|
||||
t.logger.Printf("HTTP %s %s", method, url)
|
||||
t.logger.Printf("BODY %s", reqBody)
|
||||
}
|
||||
|
||||
// Execute request
|
||||
|
|
@ -149,8 +139,7 @@ func (t *Transport) Call(ctx context.Context, method, url string, body interface
|
|||
|
||||
// Log response
|
||||
if t.logger != nil {
|
||||
t.logger.Printf("=== HTTP RESPONSE ===")
|
||||
t.logger.Printf("%s %s -> %d", method, url, resp.StatusCode)
|
||||
t.logger.Printf("HTTP %s %s -> %d", method, url, resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
|
|
@ -162,9 +151,7 @@ func (t *Transport) CallJSON(ctx context.Context, method, url string, body inter
|
|||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue