From 1413836b6808b1da4c2d2814ec28a484821d3290 Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Mon, 20 Oct 2025 13:05:36 +0200 Subject: [PATCH 01/21] feat(swagger_v2): added support for the orca staging environment --- cmd/app.go | 20 ++++-- cmd/root.go | 2 + internal/config/types.go | 69 ++++++++++++++++++- sdk/edgeconnect/appinstance.go | 29 ++++++-- sdk/edgeconnect/apps.go | 32 ++++++--- sdk/edgeconnect/types.go | 40 +++++++++-- .../comprehensive/EdgeConnectConfig.yaml | 12 ++-- .../comprehensive/k8s-deployment.yaml | 2 +- sdk/internal/http/transport.go | 19 +++-- 9 files changed, 186 insertions(+), 39 deletions(-) diff --git a/cmd/app.go b/cmd/app.go index a9f187f..0273896 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "log" "net/http" "net/url" "os" @@ -60,16 +61,23 @@ func newSDKClient() *edgeconnect.Client { os.Exit(1) } + // Build options + opts := []edgeconnect.Option{ + 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, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - ) + return edgeconnect.NewClientWithCredentials(baseURL, username, password, opts...) } // Fallback to no auth for now - in production should require auth - return edgeconnect.NewClient(baseURL, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - ) + return edgeconnect.NewClient(baseURL, opts...) } var appCmd = &cobra.Command{ diff --git a/cmd/root.go b/cmd/root.go index 480d8f5..6fa2dd6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,7 @@ var ( baseURL string username string password string + debug bool ) // rootCmd represents the base command when called without any subcommands @@ -39,6 +40,7 @@ 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().BoolVar(&debug, "debug", false, "enable debug logging") viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")) viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")) diff --git a/internal/config/types.go b/internal/config/types.go index 9b365dd..60128d4 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" "strings" + + "gopkg.in/yaml.v3" ) // EdgeConnectConfig represents the top-level configuration structure @@ -98,10 +100,75 @@ func (c *EdgeConnectConfig) GetImagePath() string { if c.Spec.IsDockerApp() && c.Spec.DockerApp.Image != "" { return c.Spec.DockerApp.Image } - // Default for kubernetes apps + + // 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 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 == "" { diff --git a/sdk/edgeconnect/appinstance.go b/sdk/edgeconnect/appinstance.go index a26f45c..ec4751a 100644 --- a/sdk/edgeconnect/appinstance.go +++ b/sdk/edgeconnect/appinstance.go @@ -4,9 +4,11 @@ package edgeconnect import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" @@ -164,18 +166,17 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK return nil } -// DeleteAppInstance removes an application instance from the specified region +// 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" - filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: appInstKey}, - Region: region, + input := DeleteAppInstanceInput{ + Key: appInstKey, } - resp, err := transport.Call(ctx, "POST", url, filter) + resp, err := transport.Call(ctx, "POST", url, input) if err != nil { return fmt.Errorf("DeleteAppInstance failed: %w", err) } @@ -194,13 +195,29 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe // parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Try parsing as a direct JSON array first (v2 API format) + switch v := result.(type) { + case *[]AppInstance: + var appInstances []AppInstance + if err := json.Unmarshal(bodyBytes, &appInstances); err == nil { + *v = appInstances + return nil + } + } + + // Fall back to streaming format (v1 API format) var appInstances []AppInstance var messages []string var hasError bool var errorCode int var errorMessage string - parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { + parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { // Try parsing as ResultResponse first (error format) var resultResp ResultResponse if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" { diff --git a/sdk/edgeconnect/apps.go b/sdk/edgeconnect/apps.go index 70f5dea..7010070 100644 --- a/sdk/edgeconnect/apps.go +++ b/sdk/edgeconnect/apps.go @@ -4,6 +4,7 @@ package edgeconnect import ( + "bytes" "context" "encoding/json" "fmt" @@ -142,12 +143,12 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp" - filter := AppFilter{ - App: App{Key: appKey}, + input := DeleteAppInput{ + Key: appKey, Region: region, } - resp, err := transport.Call(ctx, "POST", url, filter) + resp, err := transport.Call(ctx, "POST", url, input) if err != nil { return fmt.Errorf("DeleteApp failed: %w", err) } @@ -166,9 +167,27 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er // parseStreamingResponse parses the EdgeXR streaming JSON response format func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error { - var responses []Response[App] + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } - parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { + // Try parsing as a direct JSON array first (v2 API format) + switch v := result.(type) { + case *[]App: + var apps []App + if err := json.Unmarshal(bodyBytes, &apps); err == nil { + *v = apps + return nil + } + } + + // Fall back to streaming format (v1 API format) + var responses []Response[App] + var apps []App + var messages []string + + parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { var response Response[App] if err := json.Unmarshal(line, &response); err != nil { return err @@ -182,9 +201,6 @@ func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) } // Extract data from responses - var apps []App - var messages []string - for _, response := range responses { if response.HasData() { apps = append(apps, response.Data) diff --git a/sdk/edgeconnect/types.go b/sdk/edgeconnect/types.go index 7fd39fc..ffd5550 100644 --- a/sdk/edgeconnect/types.go +++ b/sdk/edgeconnect/types.go @@ -184,24 +184,33 @@ type App struct { 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"` - Flavor Flavor `json:"flavor,omitempty"` - State string `json:"state,omitempty"` - PowerState string `json:"power_state,omitempty"` - Fields []string `json:"fields,omitempty"` + 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 @@ -224,6 +233,12 @@ type Location struct { 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 @@ -256,6 +271,17 @@ type UpdateAppInstanceInput struct { AppInst AppInstance `json:"appinst"` } +// DeleteAppInput represents input for deleting an application +type DeleteAppInput struct { + Key AppKey `json:"key"` + Region string `json:"region"` +} + +// DeleteAppInstanceInput represents input for deleting an app instance +type DeleteAppInstanceInput struct { + Key AppInstanceKey `json:"key"` +} + // Response wrapper types // Response wraps a single API response diff --git a/sdk/examples/comprehensive/EdgeConnectConfig.yaml b/sdk/examples/comprehensive/EdgeConnectConfig.yaml index b45abc4..af41eaf 100644 --- a/sdk/examples/comprehensive/EdgeConnectConfig.yaml +++ b/sdk/examples/comprehensive/EdgeConnectConfig.yaml @@ -3,8 +3,8 @@ kind: edgeconnect-deployment metadata: name: "edge-app-demo" # name could be used for appName - appVersion: "1.0.0" - organization: "edp2" + appVersion: "1" + organization: "edp2-orca" spec: # dockerApp: # Docker is OBSOLETE # appVersion: "1.0.0" @@ -13,10 +13,10 @@ spec: k8sApp: manifestFile: "./k8s-deployment.yaml" infraTemplate: - - region: "EU" - cloudletOrg: "TelekomOP" - cloudletName: "Munich" - flavorName: "EU.small" + - region: "US" + cloudletOrg: "TelekomOp" + cloudletName: "gardener-shepherd-test" + flavorName: "defualt" network: outboundConnections: - protocol: "tcp" diff --git a/sdk/examples/comprehensive/k8s-deployment.yaml b/sdk/examples/comprehensive/k8s-deployment.yaml index 348b6f8..2a0a741 100644 --- a/sdk/examples/comprehensive/k8s-deployment.yaml +++ b/sdk/examples/comprehensive/k8s-deployment.yaml @@ -32,7 +32,7 @@ spec: volumes: containers: - name: edgeconnect-coder - image: nginx:latest + image: edp.buildth.ing/devfw-cicd/fibonacci_pipeline:edge_platform_demo imagePullPolicy: Always ports: - containerPort: 80 diff --git a/sdk/internal/http/transport.go b/sdk/internal/http/transport.go index 54e853c..c3bbab1 100644 --- a/sdk/internal/http/transport.go +++ b/sdk/internal/http/transport.go @@ -98,10 +98,12 @@ 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 { - jsonData, err := json.Marshal(body) + var err error + jsonData, err = json.Marshal(body) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } @@ -127,8 +129,16 @@ func (t *Transport) Call(ctx context.Context, method, url string, body interface // Log request if t.logger != nil { - t.logger.Printf("HTTP %s %s", method, url) - t.logger.Printf("BODY %s", reqBody) + 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)) + } + } } // Execute request @@ -139,7 +149,8 @@ func (t *Transport) Call(ctx context.Context, method, url string, body interface // Log response if t.logger != nil { - t.logger.Printf("HTTP %s %s -> %d", method, url, resp.StatusCode) + t.logger.Printf("=== HTTP RESPONSE ===") + t.logger.Printf("%s %s -> %d", method, url, resp.StatusCode) } return resp, nil From 3486b2228d1acacc02018e463b6aacb121c9bdd0 Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Mon, 20 Oct 2025 13:34:22 +0200 Subject: [PATCH 02/21] refactor(sdk): restructure to follow Go module versioning conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganize SDK to support both v1 and v2 APIs following Go conventions: - sdk/edgeconnect/ now contains v1 SDK (from revision/v1 branch) - sdk/edgeconnect/v2/ contains v2 SDK with package v2 - Update all CLI and internal imports to use v2 path - Update SDK examples and documentation for v2 import path 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/app.go | 26 +- cmd/instance.go | 26 +- internal/apply/manager.go | 8 +- internal/apply/manager_test.go | 42 +- internal/apply/planner.go | 24 +- internal/apply/planner_test.go | 86 ++-- internal/apply/strategy_recreate.go | 30 +- internal/apply/types.go | 10 +- sdk/README.md | 56 +-- sdk/edgeconnect/appinstance.go | 29 +- sdk/edgeconnect/apps.go | 30 +- sdk/edgeconnect/types.go | 40 +- sdk/edgeconnect/v2/appinstance.go | 281 +++++++++++++ sdk/edgeconnect/v2/appinstance_test.go | 524 +++++++++++++++++++++++++ sdk/edgeconnect/v2/apps.go | 267 +++++++++++++ sdk/edgeconnect/v2/apps_test.go | 419 ++++++++++++++++++++ sdk/edgeconnect/v2/auth.go | 184 +++++++++ sdk/edgeconnect/v2/auth_test.go | 226 +++++++++++ sdk/edgeconnect/v2/client.go | 122 ++++++ sdk/edgeconnect/v2/cloudlet.go | 271 +++++++++++++ sdk/edgeconnect/v2/cloudlet_test.go | 408 +++++++++++++++++++ sdk/edgeconnect/v2/types.go | 407 +++++++++++++++++++ sdk/examples/comprehensive/main.go | 58 +-- sdk/examples/deploy_app.go | 32 +- 24 files changed, 3328 insertions(+), 278 deletions(-) create mode 100644 sdk/edgeconnect/v2/appinstance.go create mode 100644 sdk/edgeconnect/v2/appinstance_test.go create mode 100644 sdk/edgeconnect/v2/apps.go create mode 100644 sdk/edgeconnect/v2/apps_test.go create mode 100644 sdk/edgeconnect/v2/auth.go create mode 100644 sdk/edgeconnect/v2/auth_test.go create mode 100644 sdk/edgeconnect/v2/client.go create mode 100644 sdk/edgeconnect/v2/cloudlet.go create mode 100644 sdk/edgeconnect/v2/cloudlet_test.go create mode 100644 sdk/edgeconnect/v2/types.go diff --git a/cmd/app.go b/cmd/app.go index 0273896..a96f599 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -9,7 +9,7 @@ import ( "os" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -50,7 +50,7 @@ func validateBaseURL(baseURL string) error { return nil } -func newSDKClient() *edgeconnect.Client { +func newSDKClient() *v2.Client { baseURL := viper.GetString("base_url") username := viper.GetString("username") password := viper.GetString("password") @@ -62,22 +62,22 @@ func newSDKClient() *edgeconnect.Client { } // Build options - opts := []edgeconnect.Option{ - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + 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, edgeconnect.WithLogger(logger)) + opts = append(opts, v2.WithLogger(logger)) } if username != "" && password != "" { - return edgeconnect.NewClientWithCredentials(baseURL, username, password, opts...) + return v2.NewClientWithCredentials(baseURL, username, password, opts...) } // Fallback to no auth for now - in production should require auth - return edgeconnect.NewClient(baseURL, opts...) + return v2.NewClient(baseURL, opts...) } var appCmd = &cobra.Command{ @@ -91,10 +91,10 @@ var createAppCmd = &cobra.Command{ Short: "Create a new Edge Connect application", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - input := &edgeconnect.NewAppInput{ + input := &v2.NewAppInput{ Region: region, - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ + App: v2.App{ + Key: v2.AppKey{ Organization: organization, Name: appName, Version: appVersion, @@ -116,7 +116,7 @@ var showAppCmd = &cobra.Command{ Short: "Show details of an Edge Connect application", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: organization, Name: appName, Version: appVersion, @@ -136,7 +136,7 @@ var listAppsCmd = &cobra.Command{ Short: "List Edge Connect applications", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: organization, Name: appName, Version: appVersion, @@ -159,7 +159,7 @@ var deleteAppCmd = &cobra.Command{ Short: "Delete an Edge Connect application", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: organization, Name: appName, Version: appVersion, diff --git a/cmd/instance.go b/cmd/instance.go index de22062..30194ab 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" "github.com/spf13/cobra" ) @@ -27,23 +27,23 @@ var createInstanceCmd = &cobra.Command{ Short: "Create a new Edge Connect application instance", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - input := &edgeconnect.NewAppInstanceInput{ + input := &v2.NewAppInstanceInput{ Region: region, - AppInst: edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ + AppInst: v2.AppInstance{ + Key: v2.AppInstanceKey{ Organization: organization, Name: instanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: cloudletOrg, Name: cloudletName, }, }, - AppKey: edgeconnect.AppKey{ + AppKey: v2.AppKey{ Organization: organization, Name: appName, Version: appVersion, }, - Flavor: edgeconnect.Flavor{ + Flavor: v2.Flavor{ Name: flavorName, }, }, @@ -63,10 +63,10 @@ var showInstanceCmd = &cobra.Command{ Short: "Show details of an Edge Connect application instance", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: organization, Name: instanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: cloudletOrg, Name: cloudletName, }, @@ -86,10 +86,10 @@ var listInstancesCmd = &cobra.Command{ Short: "List Edge Connect application instances", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: organization, Name: instanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: cloudletOrg, Name: cloudletName, }, @@ -112,10 +112,10 @@ var deleteInstanceCmd = &cobra.Command{ Short: "Delete an Edge Connect application instance", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: organization, Name: instanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: cloudletOrg, Name: cloudletName, }, diff --git a/internal/apply/manager.go b/internal/apply/manager.go index 45477ab..3e6d837 100644 --- a/internal/apply/manager.go +++ b/internal/apply/manager.go @@ -8,7 +8,7 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ) // ResourceManagerInterface defines the interface for resource management @@ -250,7 +250,7 @@ func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context, // rollbackApp deletes an application that was created func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error { - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: plan.AppAction.Desired.Organization, Name: plan.AppAction.Desired.Name, Version: plan.AppAction.Desired.Version, @@ -264,10 +264,10 @@ func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, acti // Find the instance action to get the details for _, instanceAction := range plan.InstanceActions { if instanceAction.InstanceName == action.Target { - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: plan.AppAction.Desired.Organization, Name: instanceAction.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: instanceAction.Target.CloudletOrg, Name: instanceAction.Target.CloudletName, }, diff --git a/internal/apply/manager_test.go b/internal/apply/manager_test.go index 6060a37..f2135b5 100644 --- a/internal/apply/manager_test.go +++ b/internal/apply/manager_test.go @@ -11,7 +11,7 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -22,32 +22,32 @@ type MockResourceClient struct { MockEdgeConnectClient } -func (m *MockResourceClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { +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 *edgeconnect.NewAppInstanceInput) error { +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 edgeconnect.AppKey, region string) error { +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 *edgeconnect.UpdateAppInput) error { +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 *edgeconnect.UpdateAppInstanceInput) error { +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 edgeconnect.AppInstanceKey, region string) error { +func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error { args := m.Called(ctx, instanceKey, region) return args.Error(0) } @@ -185,9 +185,9 @@ func TestApplyDeploymentSuccess(t *testing.T) { config := createTestManagerConfig(t) // Mock successful operations - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). + mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")). Return(nil) - mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). + mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")). Return(nil) ctx := context.Background() @@ -216,8 +216,8 @@ func TestApplyDeploymentAppFailure(t *testing.T) { config := createTestManagerConfig(t) // Mock app creation failure - deployment should stop here - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) + 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") @@ -241,13 +241,13 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { config := createTestManagerConfig(t) // Mock successful app creation but failed instance creation - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). + mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")). Return(nil) - mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}}) + 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("edgeconnect.AppKey"), "US"). + mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). Return(nil) ctx := context.Background() @@ -333,9 +333,9 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) { config := createTestManagerConfig(t) // Mock successful operations - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). + mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")). Return(nil) - mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). + mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")). Return(nil) ctx := context.Background() @@ -421,9 +421,9 @@ func TestRollbackDeployment(t *testing.T) { } // Mock rollback operations - mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). + mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US"). Return(nil) - mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). + mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). Return(nil) ctx := context.Background() @@ -453,8 +453,8 @@ func TestRollbackDeploymentFailure(t *testing.T) { } // Mock rollback failure - mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Delete failed"}}) + 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) diff --git a/internal/apply/planner.go b/internal/apply/planner.go index 1cbc58d..d4f3e82 100644 --- a/internal/apply/planner.go +++ b/internal/apply/planner.go @@ -12,19 +12,19 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ) // EdgeConnectClientInterface defines the methods needed for deployment planning type EdgeConnectClientInterface interface { - ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) - CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error - UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error - DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error - ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) - CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error - UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error - DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error + 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, 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 @@ -285,7 +285,7 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: desired.Organization, Name: desired.Name, Version: desired.Version, @@ -339,10 +339,10 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: desired.Organization, Name: desired.Name, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: desired.CloudletOrg, Name: desired.CloudletName, }, diff --git a/internal/apply/planner_test.go b/internal/apply/planner_test.go index d946a14..6f7c39b 100644 --- a/internal/apply/planner_test.go +++ b/internal/apply/planner_test.go @@ -10,7 +10,7 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -21,66 +21,66 @@ type MockEdgeConnectClient struct { mock.Mock } -func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) { +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 edgeconnect.App{}, args.Error(1) + return v2.App{}, args.Error(1) } - return args.Get(0).(edgeconnect.App), args.Error(1) + return args.Get(0).(v2.App), args.Error(1) } -func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) { +func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) (v2.AppInstance, error) { args := m.Called(ctx, instanceKey, region) if args.Get(0) == nil { - return edgeconnect.AppInstance{}, args.Error(1) + return v2.AppInstance{}, args.Error(1) } - return args.Get(0).(edgeconnect.AppInstance), args.Error(1) + return args.Get(0).(v2.AppInstance), args.Error(1) } -func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { +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 *edgeconnect.NewAppInstanceInput) error { +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 edgeconnect.AppKey, region string) error { +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 *edgeconnect.UpdateAppInput) error { +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 *edgeconnect.UpdateAppInstanceInput) error { +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 edgeconnect.AppInstanceKey, region string) error { +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 edgeconnect.AppKey, region string) ([]edgeconnect.App, error) { +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).([]edgeconnect.App), args.Error(1) + return args.Get(0).([]v2.App), args.Error(1) } -func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) { +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).([]edgeconnect.AppInstance), args.Error(1) + return args.Get(0).([]v2.AppInstance), args.Error(1) } func TestNewPlanner(t *testing.T) { @@ -148,11 +148,11 @@ func TestPlanNewDeployment(t *testing.T) { testConfig := createTestConfig(t) // Mock API calls to return "not found" errors - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) + mockClient.On("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("edgeconnect.AppInstanceKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance 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) @@ -186,15 +186,15 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) { // Mock existing app with same manifest hash and outbound connections manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" - existingApp := &edgeconnect.App{ - Key: edgeconnect.AppKey{ + existingApp := &v2.App{ + Key: v2.AppKey{ Organization: "testorg", Name: "test-app", Version: "1.0.0", }, Deployment: "kubernetes", DeploymentManifest: manifestContent, - RequiredOutboundConnections: []edgeconnect.SecurityRule{ + RequiredOutboundConnections: []v2.SecurityRule{ { Protocol: "tcp", PortRangeMin: 80, @@ -206,31 +206,31 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) { } // Mock existing instance - existingInstance := &edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ + existingInstance := &v2.AppInstance{ + Key: v2.AppInstanceKey{ Organization: "testorg", Name: "test-app-1.0.0-instance", - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: "TestCloudletOrg", Name: "TestCloudlet", }, }, - AppKey: edgeconnect.AppKey{ + AppKey: v2.AppKey{ Organization: "testorg", Name: "test-app", Version: "1.0.0", }, - Flavor: edgeconnect.Flavor{ + Flavor: v2.Flavor{ Name: "small", }, State: "Ready", PowerState: "PowerOn", } - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). Return(*existingApp, nil) - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). + mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US"). Return(*existingInstance, nil) ctx := context.Background() @@ -293,14 +293,14 @@ func TestPlanMultipleInfrastructures(t *testing.T) { }) // Mock API calls to return "not found" errors - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) + mockClient.On("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("edgeconnect.AppInstanceKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance 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("edgeconnect.AppInstanceKey"), "EU"). - Return(nil, &edgeconnect.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) @@ -628,10 +628,10 @@ func TestIsResourceNotFoundError(t *testing.T) { expected bool }{ {"nil error", nil, false}, - {"not found error", &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true}, - {"does not exist error", &edgeconnect.APIError{Messages: []string{"App does not exist"}}, true}, - {"404 in message", &edgeconnect.APIError{Messages: []string{"HTTP 404 error"}}, true}, - {"other error", &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false}, + {"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 { @@ -648,8 +648,8 @@ func TestPlanErrorHandling(t *testing.T) { testConfig := createTestConfig(t) // Mock API call to return a non-404 error - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) + 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) diff --git a/internal/apply/strategy_recreate.go b/internal/apply/strategy_recreate.go index 4e69e7d..dc44784 100644 --- a/internal/apply/strategy_recreate.go +++ b/internal/apply/strategy_recreate.go @@ -11,7 +11,7 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ) // RecreateStrategy implements the recreate deployment strategy @@ -184,7 +184,7 @@ func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentP r.logf("Phase 2: Deleting existing application") - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: plan.AppAction.Desired.Organization, Name: plan.AppAction.Desired.Name, Version: plan.AppAction.Desired.Version, @@ -426,10 +426,10 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action // deleteInstance deletes an instance (reuse existing logic from manager.go) func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) { - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: action.Desired.Organization, Name: action.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: action.Target.CloudletOrg, Name: action.Target.CloudletName, }, @@ -445,23 +445,23 @@ func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAc // createInstance creates an instance (extracted from manager.go logic) func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) { - instanceInput := &edgeconnect.NewAppInstanceInput{ + instanceInput := &v2.NewAppInstanceInput{ Region: action.Target.Region, - AppInst: edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ + AppInst: v2.AppInstance{ + Key: v2.AppInstanceKey{ Organization: action.Desired.Organization, Name: action.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: action.Target.CloudletOrg, Name: action.Target.CloudletName, }, }, - AppKey: edgeconnect.AppKey{ + AppKey: v2.AppKey{ Organization: action.Desired.Organization, Name: config.Metadata.Name, Version: config.Metadata.AppVersion, }, - Flavor: edgeconnect.Flavor{ + Flavor: v2.Flavor{ Name: action.Target.FlavorName, }, }, @@ -481,10 +481,10 @@ func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAc // updateApplication creates/recreates an application (always uses CreateApp since we delete first) func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) { // Build the app create input - always create since recreate strategy deletes first - appInput := &edgeconnect.NewAppInput{ + appInput := &v2.NewAppInput{ Region: action.Desired.Region, - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ + App: v2.App{ + Key: v2.AppKey{ Organization: action.Desired.Organization, Name: action.Desired.Name, Version: action.Desired.Version, @@ -493,7 +493,7 @@ func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppActi ImageType: "ImageTypeDocker", ImagePath: config.GetImagePath(), AllowServerless: true, - DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, + DefaultFlavor: v2.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, ServerlessConfig: struct{}{}, DeploymentManifest: manifestContent, DeploymentGenerator: "kubernetes-basic", @@ -531,7 +531,7 @@ func isRetryableError(err error) bool { } // Check if it's an APIError with a status code - var apiErr *edgeconnect.APIError + var apiErr *v2.APIError if errors.As(err, &apiErr) { // Don't retry client errors (4xx) if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { diff --git a/internal/apply/types.go b/internal/apply/types.go index 6f7ef4e..279832a 100644 --- a/internal/apply/types.go +++ b/internal/apply/types.go @@ -8,11 +8,11 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ) // SecurityRule defines network access rules (alias to SDK type for consistency) -type SecurityRule = edgeconnect.SecurityRule +type SecurityRule = v2.SecurityRule // ActionType represents the type of action to be performed type ActionType string @@ -446,11 +446,11 @@ func (dp *DeploymentPlan) Clone() *DeploymentPlan { } // convertNetworkRules converts config network rules to EdgeConnect SecurityRules -func convertNetworkRules(network *config.NetworkConfig) []edgeconnect.SecurityRule { - rules := make([]edgeconnect.SecurityRule, len(network.OutboundConnections)) +func convertNetworkRules(network *config.NetworkConfig) []v2.SecurityRule { + rules := make([]v2.SecurityRule, len(network.OutboundConnections)) for i, conn := range network.OutboundConnections { - rules[i] = edgeconnect.SecurityRule{ + rules[i] = v2.SecurityRule{ Protocol: conn.Protocol, PortRangeMin: conn.PortRangeMin, PortRangeMax: conn.PortRangeMax, diff --git a/sdk/README.md b/sdk/README.md index 0f16b12..89dc673 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -16,18 +16,18 @@ A comprehensive Go SDK for the EdgeXR Master Controller API, providing typed int ### Installation ```go -import "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" +import v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ``` ### Authentication ```go // Username/password (recommended) -client := client.NewClientWithCredentials(baseURL, username, password) +client := v2.NewClientWithCredentials(baseURL, username, password) // Static Bearer token -client := client.NewClient(baseURL, - client.WithAuthProvider(client.NewStaticTokenProvider(token))) +client := v2.NewClient(baseURL, + v2.WithAuthProvider(v2.NewStaticTokenProvider(token))) ``` ### Basic Usage @@ -36,10 +36,10 @@ client := client.NewClient(baseURL, ctx := context.Background() // Create an application -app := &client.NewAppInput{ +app := &v2.NewAppInput{ Region: "us-west", - App: client.App{ - Key: client.AppKey{ + App: v2.App{ + Key: v2.AppKey{ Organization: "myorg", Name: "my-app", Version: "1.0.0", @@ -49,28 +49,28 @@ app := &client.NewAppInput{ }, } -if err := client.CreateApp(ctx, app); err != nil { +if err := v2.CreateApp(ctx, app); err != nil { log.Fatal(err) } // Deploy an application instance -instance := &client.NewAppInstanceInput{ +instance := &v2.NewAppInstanceInput{ Region: "us-west", - AppInst: client.AppInstance{ - Key: client.AppInstanceKey{ + AppInst: v2.AppInstance{ + Key: v2.AppInstanceKey{ Organization: "myorg", Name: "my-instance", - CloudletKey: client.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: "cloudlet-provider", Name: "edge-cloudlet", }, }, AppKey: app.App.Key, - Flavor: client.Flavor{Name: "m4.small"}, + Flavor: v2.Flavor{Name: "m4.small"}, }, } -if err := client.CreateAppInstance(ctx, instance); err != nil { +if err := v2.CreateAppInstance(ctx, instance); err != nil { log.Fatal(err) } ``` @@ -101,22 +101,22 @@ if err := client.CreateAppInstance(ctx, instance); err != nil { ## Configuration Options ```go -client := client.NewClient(baseURL, +client := v2.NewClient(baseURL, // Custom HTTP client with timeout - client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), // Authentication provider - client.WithAuthProvider(client.NewStaticTokenProvider(token)), + v2.WithAuthProvider(v2.NewStaticTokenProvider(token)), // Retry configuration - client.WithRetryOptions(client.RetryOptions{ + v2.WithRetryOptions(v2.RetryOptions{ MaxRetries: 5, InitialDelay: 1 * time.Second, MaxDelay: 30 * time.Second, }), // Request logging - client.WithLogger(log.Default()), + v2.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 := client.NewClientWithCredentials(baseURL, username, password) +client := v2.NewClientWithCredentials(baseURL, username, password) ``` **Features:** @@ -154,23 +154,23 @@ client := client.NewClientWithCredentials(baseURL, username, password) For pre-obtained tokens: ```go -client := client.NewClient(baseURL, - client.WithAuthProvider(client.NewStaticTokenProvider(token))) +client := v2.NewClient(baseURL, + v2.WithAuthProvider(v2.NewStaticTokenProvider(token))) ``` ## Error Handling ```go -app, err := client.ShowApp(ctx, appKey, region) +app, err := v2.ShowApp(ctx, appKey, region) if err != nil { // Check for specific error types - if errors.Is(err, client.ErrResourceNotFound) { + if errors.Is(err, v2.ErrResourceNotFound) { fmt.Println("App not found") return } // Check for API errors - var apiErr *client.APIError + var apiErr *v2.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 := &client.EdgeConnect{ +oldClient := &v2.EdgeConnect{ BaseURL: baseURL, - Credentials: client.Credentials{Username: user, Password: pass}, + Credentials: v2.Credentials{Username: user, Password: pass}, } // New SDK approach -newClient := client.NewClientWithCredentials(baseURL, user, pass) +newClient := v2.NewClientWithCredentials(baseURL, user, pass) // Same method calls, enhanced reliability err := newClient.CreateApp(ctx, input) diff --git a/sdk/edgeconnect/appinstance.go b/sdk/edgeconnect/appinstance.go index ec4751a..a26f45c 100644 --- a/sdk/edgeconnect/appinstance.go +++ b/sdk/edgeconnect/appinstance.go @@ -4,11 +4,9 @@ package edgeconnect import ( - "bytes" "context" "encoding/json" "fmt" - "io" "net/http" sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" @@ -166,17 +164,18 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK return nil } -// DeleteAppInstance removes an application instance +// DeleteAppInstance removes an application instance from the specified region // Maps to POST /auth/ctrl/DeleteAppInst func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst" - input := DeleteAppInstanceInput{ - Key: appInstKey, + filter := AppInstanceFilter{ + AppInstance: AppInstance{Key: appInstKey}, + Region: region, } - resp, err := transport.Call(ctx, "POST", url, input) + resp, err := transport.Call(ctx, "POST", url, filter) if err != nil { return fmt.Errorf("DeleteAppInstance failed: %w", err) } @@ -195,29 +194,13 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe // parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error { - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - // Try parsing as a direct JSON array first (v2 API format) - switch v := result.(type) { - case *[]AppInstance: - var appInstances []AppInstance - if err := json.Unmarshal(bodyBytes, &appInstances); err == nil { - *v = appInstances - return nil - } - } - - // Fall back to streaming format (v1 API format) var appInstances []AppInstance var messages []string var hasError bool var errorCode int var errorMessage string - parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { + parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { // Try parsing as ResultResponse first (error format) var resultResp ResultResponse if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" { diff --git a/sdk/edgeconnect/apps.go b/sdk/edgeconnect/apps.go index 7010070..70f5dea 100644 --- a/sdk/edgeconnect/apps.go +++ b/sdk/edgeconnect/apps.go @@ -4,7 +4,6 @@ package edgeconnect import ( - "bytes" "context" "encoding/json" "fmt" @@ -143,12 +142,12 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp" - input := DeleteAppInput{ - Key: appKey, + filter := AppFilter{ + App: App{Key: appKey}, Region: region, } - resp, err := transport.Call(ctx, "POST", url, input) + resp, err := transport.Call(ctx, "POST", url, filter) if err != nil { return fmt.Errorf("DeleteApp failed: %w", err) } @@ -167,27 +166,9 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er // parseStreamingResponse parses the EdgeXR streaming JSON response format func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error { - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - // Try parsing as a direct JSON array first (v2 API format) - switch v := result.(type) { - case *[]App: - var apps []App - if err := json.Unmarshal(bodyBytes, &apps); err == nil { - *v = apps - return nil - } - } - - // Fall back to streaming format (v1 API format) var responses []Response[App] - var apps []App - var messages []string - parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { + parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { var response Response[App] if err := json.Unmarshal(line, &response); err != nil { return err @@ -201,6 +182,9 @@ func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) } // Extract data from responses + var apps []App + var messages []string + for _, response := range responses { if response.HasData() { apps = append(apps, response.Data) diff --git a/sdk/edgeconnect/types.go b/sdk/edgeconnect/types.go index ffd5550..7fd39fc 100644 --- a/sdk/edgeconnect/types.go +++ b/sdk/edgeconnect/types.go @@ -184,33 +184,24 @@ type App struct { 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"` + msg `json:",inline"` + Key AppInstanceKey `json:"key"` + AppKey AppKey `json:"app_key,omitempty"` + Flavor Flavor `json:"flavor,omitempty"` + State string `json:"state,omitempty"` + PowerState string `json:"power_state,omitempty"` + Fields []string `json:"fields,omitempty"` } // Cloudlet represents edge infrastructure @@ -233,12 +224,6 @@ type Location struct { 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 @@ -271,17 +256,6 @@ type UpdateAppInstanceInput struct { AppInst AppInstance `json:"appinst"` } -// DeleteAppInput represents input for deleting an application -type DeleteAppInput struct { - Key AppKey `json:"key"` - Region string `json:"region"` -} - -// DeleteAppInstanceInput represents input for deleting an app instance -type DeleteAppInstanceInput struct { - Key AppInstanceKey `json:"key"` -} - // Response wrapper types // Response wraps a single API response diff --git a/sdk/edgeconnect/v2/appinstance.go b/sdk/edgeconnect/v2/appinstance.go new file mode 100644 index 0000000..57e6b3c --- /dev/null +++ b/sdk/edgeconnect/v2/appinstance.go @@ -0,0 +1,281 @@ +// 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" + + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" +) + +// CreateAppInstance creates a new application instance in the specified region +// Maps to POST /auth/ctrl/CreateAppInst +func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInput) error { + + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/CreateAppInst" + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("CreateAppInstance failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.handleErrorResponse(resp, "CreateAppInstance") + } + + // Parse streaming JSON response + var appInstances []AppInstance + if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); 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, region string) (AppInstance, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" + + filter := AppInstanceFilter{ + AppInstance: AppInstance{Key: appInstKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return AppInstance{}, fmt.Errorf("app instance %s/%s: %w", + appInstKey.Organization, appInstKey.Name, ErrResourceNotFound) + } + + if resp.StatusCode >= 400 { + return AppInstance{}, c.handleErrorResponse(resp, "ShowAppInstance") + } + + // Parse streaming JSON response + var appInstances []AppInstance + if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + return AppInstance{}, fmt.Errorf("ShowAppInstance failed to parse response: %w", err) + } + + if len(appInstances) == 0 { + return AppInstance{}, fmt.Errorf("app instance %s/%s in region %s: %w", + appInstKey.Organization, appInstKey.Name, region, ErrResourceNotFound) + } + + return appInstances[0], nil +} + +// ShowAppInstances retrieves all application instances matching the filter criteria +// Maps to POST /auth/ctrl/ShowAppInst +func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, region string) ([]AppInstance, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" + + filter := AppInstanceFilter{ + AppInstance: AppInstance{Key: appInstKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return nil, fmt.Errorf("ShowAppInstances failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { + return nil, c.handleErrorResponse(resp, "ShowAppInstances") + } + + var appInstances []AppInstance + if resp.StatusCode == http.StatusNotFound { + return appInstances, nil // Return empty slice for not found + } + + if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err) + } + + c.logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances)) + return appInstances, nil +} + +// UpdateAppInstance updates an application instance and then refreshes it +// Maps to POST /auth/ctrl/UpdateAppInst +func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstanceInput) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/UpdateAppInst" + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("UpdateAppInstance failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.handleErrorResponse(resp, "UpdateAppInstance") + } + + c.logf("UpdateAppInstance: %s/%s updated successfully", + input.AppInst.Key.Organization, input.AppInst.Key.Name) + + return nil +} + +// RefreshAppInstance refreshes an application instance's state +// Maps to POST /auth/ctrl/RefreshAppInst +func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/RefreshAppInst" + + filter := AppInstanceFilter{ + AppInstance: AppInstance{Key: appInstKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return fmt.Errorf("RefreshAppInstance failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.handleErrorResponse(resp, "RefreshAppInstance") + } + + c.logf("RefreshAppInstance: %s/%s refreshed successfully", + appInstKey.Organization, appInstKey.Name) + + return nil +} + +// DeleteAppInstance removes an application instance +// 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{ + Key: appInstKey, + } + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("DeleteAppInstance failed: %w", err) + } + defer resp.Body.Close() + + // 404 is acceptable for delete operations (already deleted) + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { + return c.handleErrorResponse(resp, "DeleteAppInstance") + } + + c.logf("DeleteAppInstance: %s/%s deleted successfully", + appInstKey.Organization, appInstKey.Name) + + return nil +} + +// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances +func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Try parsing as a direct JSON array first (v2 API format) + switch v := result.(type) { + case *[]AppInstance: + var appInstances []AppInstance + if err := json.Unmarshal(bodyBytes, &appInstances); err == nil { + *v = appInstances + return nil + } + } + + // Fall back to streaming format (v1 API format) + var appInstances []AppInstance + var messages []string + var hasError bool + var errorCode int + var errorMessage string + + parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { + // Try parsing as ResultResponse first (error format) + var resultResp ResultResponse + if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" { + if resultResp.IsError() { + hasError = true + errorCode = resultResp.GetCode() + errorMessage = resultResp.GetMessage() + } + return nil + } + + // Try parsing as Response[AppInstance] + var response Response[AppInstance] + if err := json.Unmarshal(line, &response); err != nil { + return err + } + + if response.HasData() { + appInstances = append(appInstances, response.Data) + } + if response.IsMessage() { + msg := response.Data.GetMessage() + messages = append(messages, msg) + // Check for error indicators in messages + if msg == "CreateError" || msg == "UpdateError" || msg == "DeleteError" { + hasError = true + } + } + return nil + }) + + if parseErr != nil { + return parseErr + } + + // If we detected an error, return it + if hasError { + apiErr := &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + } + if errorCode > 0 { + apiErr.StatusCode = errorCode + apiErr.Code = fmt.Sprintf("%d", errorCode) + } + if errorMessage != "" { + apiErr.Messages = append([]string{errorMessage}, apiErr.Messages...) + } + return apiErr + } + + // Set result based on type + switch v := result.(type) { + case *[]AppInstance: + *v = appInstances + default: + return fmt.Errorf("unsupported result type: %T", result) + } + + return nil +} diff --git a/sdk/edgeconnect/v2/appinstance_test.go b/sdk/edgeconnect/v2/appinstance_test.go new file mode 100644 index 0000000..e1c3d5e --- /dev/null +++ b/sdk/edgeconnect/v2/appinstance_test.go @@ -0,0 +1,524 @@ +// 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 + region string + mockStatusCode int + mockResponse string + expectError bool + expectNotFound bool + }{ + { + name: "successful show", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 200, + mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}} +`, + expectError: false, + expectNotFound: false, + }, + { + name: "instance not found", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "nonexistent", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 404, + mockResponse: "", + expectError: true, + expectNotFound: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path) + + w.WriteHeader(tt.mockStatusCode) + if tt.mockResponse != "" { + w.Write([]byte(tt.mockResponse)) + } + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, + WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), + ) + + // Execute test + ctx := context.Background() + appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region) + + // Verify results + if tt.expectError { + assert.Error(t, err) + if tt.expectNotFound { + assert.Contains(t, err.Error(), "resource not found") + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.appInstKey.Organization, appInst.Key.Organization) + assert.Equal(t, tt.appInstKey.Name, appInst.Key.Name) + assert.Equal(t, "Ready", appInst.State) + } + }) + } +} + +func TestShowAppInstances(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path) + + // Verify request body + var filter AppInstanceFilter + err := json.NewDecoder(r.Body).Decode(&filter) + require.NoError(t, err) + assert.Equal(t, "testorg", filter.AppInstance.Key.Organization) + assert.Equal(t, "us-west", filter.Region) + + // Return multiple app instances + response := `{"data": {"key": {"organization": "testorg", "name": "inst1"}, "state": "Ready"}} +{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}} +` + w.WriteHeader(200) + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL) + ctx := context.Background() + + appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, "us-west") + + require.NoError(t, err) + assert.Len(t, appInstances, 2) + assert.Equal(t, "inst1", appInstances[0].Key.Name) + assert.Equal(t, "Ready", appInstances[0].State) + assert.Equal(t, "inst2", appInstances[1].Key.Name) + assert.Equal(t, "Creating", appInstances[1].State) +} + +func TestUpdateAppInstance(t *testing.T) { + tests := []struct { + name string + input *UpdateAppInstanceInput + mockStatusCode int + mockResponse string + expectError bool + }{ + { + name: "successful update", + input: &UpdateAppInstanceInput{ + Region: "us-west", + AppInst: AppInstance{ + Key: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + AppKey: AppKey{ + Organization: "testorg", + Name: "testapp", + Version: "1.0.0", + }, + Flavor: Flavor{Name: "m4.medium"}, + PowerState: "PowerOn", + }, + }, + mockStatusCode: 200, + mockResponse: `{"message": "success"}`, + expectError: false, + }, + { + name: "validation error", + input: &UpdateAppInstanceInput{ + Region: "us-west", + AppInst: AppInstance{ + Key: AppInstanceKey{ + Organization: "", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + }, + }, + mockStatusCode: 400, + mockResponse: `{"message": "organization is required"}`, + expectError: true, + }, + { + name: "instance not found", + input: &UpdateAppInstanceInput{ + Region: "us-west", + AppInst: AppInstance{ + Key: AppInstanceKey{ + Organization: "testorg", + Name: "nonexistent", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + }, + }, + mockStatusCode: 404, + mockResponse: `{"message": "app instance not found"}`, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/UpdateAppInst", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + // Verify request body + var input UpdateAppInstanceInput + err := json.NewDecoder(r.Body).Decode(&input) + require.NoError(t, err) + assert.Equal(t, tt.input.Region, input.Region) + assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization) + + w.WriteHeader(tt.mockStatusCode) + w.Write([]byte(tt.mockResponse)) + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, + WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), + WithAuthProvider(NewStaticTokenProvider("test-token")), + ) + + // Execute test + ctx := context.Background() + err := client.UpdateAppInstance(ctx, tt.input) + + // Verify results + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestRefreshAppInstance(t *testing.T) { + tests := []struct { + name string + appInstKey AppInstanceKey + region string + mockStatusCode int + expectError bool + }{ + { + name: "successful refresh", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 200, + expectError: false, + }, + { + name: "server error", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 500, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/RefreshAppInst", r.URL.Path) + + w.WriteHeader(tt.mockStatusCode) + })) + defer server.Close() + + client := NewClient(server.URL) + ctx := context.Background() + + err := client.RefreshAppInstance(ctx, tt.appInstKey, tt.region) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestDeleteAppInstance(t *testing.T) { + tests := []struct { + name string + appInstKey AppInstanceKey + region string + mockStatusCode int + expectError bool + }{ + { + name: "successful deletion", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 200, + expectError: false, + }, + { + name: "already deleted (404 ok)", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 404, + expectError: false, + }, + { + name: "server error", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 500, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/DeleteAppInst", r.URL.Path) + + w.WriteHeader(tt.mockStatusCode) + })) + defer server.Close() + + client := NewClient(server.URL) + ctx := context.Background() + + err := client.DeleteAppInstance(ctx, tt.appInstKey, tt.region) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/sdk/edgeconnect/v2/apps.go b/sdk/edgeconnect/v2/apps.go new file mode 100644 index 0000000..ce5bb76 --- /dev/null +++ b/sdk/edgeconnect/v2/apps.go @@ -0,0 +1,267 @@ +// ABOUTME: Application lifecycle management APIs for EdgeXR Master Controller +// ABOUTME: Provides typed methods for creating, querying, and deleting applications + +package v2 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" +) + +var ( + // ErrResourceNotFound indicates the requested resource was not found + ErrResourceNotFound = fmt.Errorf("resource not found") +) + +// CreateApp creates a new application in the specified region +// Maps to POST /auth/ctrl/CreateApp +func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/CreateApp" + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("CreateApp failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.handleErrorResponse(resp, "CreateApp") + } + + c.logf("CreateApp: %s/%s version %s created successfully", + input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version) + + return nil +} + +// ShowApp retrieves a single application by key and region +// Maps to POST /auth/ctrl/ShowApp +func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp" + + filter := AppFilter{ + App: App{Key: appKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return App{}, fmt.Errorf("ShowApp failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", + appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound) + } + + if resp.StatusCode >= 400 { + return App{}, c.handleErrorResponse(resp, "ShowApp") + } + + // Parse streaming JSON response + var apps []App + if err := c.parseStreamingResponse(resp, &apps); err != nil { + return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err) + } + + if len(apps) == 0 { + return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", + appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound) + } + + return apps[0], nil +} + +// ShowApps retrieves all applications matching the filter criteria +// Maps to POST /auth/ctrl/ShowApp +func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]App, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp" + + filter := AppFilter{ + App: App{Key: appKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return nil, fmt.Errorf("ShowApps failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { + return nil, c.handleErrorResponse(resp, "ShowApps") + } + + var apps []App + if resp.StatusCode == http.StatusNotFound { + return apps, nil // Return empty slice for not found + } + + if err := c.parseStreamingResponse(resp, &apps); err != nil { + return nil, fmt.Errorf("ShowApps failed to parse response: %w", err) + } + + c.logf("ShowApps: found %d apps matching criteria", len(apps)) + return apps, nil +} + +// UpdateApp updates the definition of an application +// Maps to POST /auth/ctrl/UpdateApp +func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/UpdateApp" + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("UpdateApp failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.handleErrorResponse(resp, "UpdateApp") + } + + c.logf("UpdateApp: %s/%s version %s updated successfully", + input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version) + + return nil +} + +// DeleteApp removes an application from the specified region +// Maps to POST /auth/ctrl/DeleteApp +func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp" + + input := DeleteAppInput{ + Key: appKey, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("DeleteApp failed: %w", err) + } + defer resp.Body.Close() + + // 404 is acceptable for delete operations (already deleted) + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { + return c.handleErrorResponse(resp, "DeleteApp") + } + + c.logf("DeleteApp: %s/%s version %s deleted successfully", + appKey.Organization, appKey.Name, appKey.Version) + + return nil +} + +// parseStreamingResponse parses the EdgeXR streaming JSON response format +func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Try parsing as a direct JSON array first (v2 API format) + switch v := result.(type) { + case *[]App: + var apps []App + if err := json.Unmarshal(bodyBytes, &apps); err == nil { + *v = apps + return nil + } + } + + // Fall back to streaming format (v1 API format) + var responses []Response[App] + var apps []App + var messages []string + + parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { + var response Response[App] + if err := json.Unmarshal(line, &response); err != nil { + return err + } + responses = append(responses, response) + return nil + }) + + if parseErr != nil { + return parseErr + } + + // Extract data from responses + for _, response := range responses { + if response.HasData() { + apps = append(apps, response.Data) + } + if response.IsMessage() { + messages = append(messages, response.Data.GetMessage()) + } + } + + // If we have error messages, return them + if len(messages) > 0 { + return &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + } + } + + // Set result based on type + switch v := result.(type) { + case *[]App: + *v = apps + default: + return fmt.Errorf("unsupported result type: %T", result) + } + + return nil +} + +// getTransport creates an HTTP transport with current client settings +func (c *Client) getTransport() *sdkhttp.Transport { + return sdkhttp.NewTransport( + sdkhttp.RetryOptions{ + MaxRetries: c.RetryOpts.MaxRetries, + InitialDelay: c.RetryOpts.InitialDelay, + MaxDelay: c.RetryOpts.MaxDelay, + Multiplier: c.RetryOpts.Multiplier, + RetryableHTTPStatusCodes: c.RetryOpts.RetryableHTTPStatusCodes, + }, + c.AuthProvider, + c.Logger, + ) +} + +// handleErrorResponse creates an appropriate error from HTTP error response +func (c *Client) handleErrorResponse(resp *http.Response, operation string) error { + + messages := []string{ + fmt.Sprintf("%s failed with status %d", operation, resp.StatusCode), + } + + bodyBytes := []byte{} + + if resp.Body != nil { + defer resp.Body.Close() + bodyBytes, _ = io.ReadAll(resp.Body) + messages = append(messages, string(bodyBytes)) + } + + return &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + Body: bodyBytes, + } +} diff --git a/sdk/edgeconnect/v2/apps_test.go b/sdk/edgeconnect/v2/apps_test.go new file mode 100644 index 0000000..4ea757c --- /dev/null +++ b/sdk/edgeconnect/v2/apps_test.go @@ -0,0 +1,419 @@ +// 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) +} + +// Helper function to create a test server that handles streaming JSON responses +func createStreamingJSONServer(responses []string, statusCode int) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(statusCode) + for _, response := range responses { + w.Write([]byte(response + "\n")) + } + })) +} diff --git a/sdk/edgeconnect/v2/auth.go b/sdk/edgeconnect/v2/auth.go new file mode 100644 index 0000000..a1f33a2 --- /dev/null +++ b/sdk/edgeconnect/v2/auth.go @@ -0,0 +1,184 @@ +// 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 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 +} diff --git a/sdk/edgeconnect/v2/auth_test.go b/sdk/edgeconnect/v2/auth_test.go new file mode 100644 index 0000000..0fc5b24 --- /dev/null +++ b/sdk/edgeconnect/v2/auth_test.go @@ -0,0 +1,226 @@ +// 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) +} diff --git a/sdk/edgeconnect/v2/client.go b/sdk/edgeconnect/v2/client.go new file mode 100644 index 0000000..6846b83 --- /dev/null +++ b/sdk/edgeconnect/v2/client.go @@ -0,0 +1,122 @@ +// 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...) + } +} diff --git a/sdk/edgeconnect/v2/cloudlet.go b/sdk/edgeconnect/v2/cloudlet.go new file mode 100644 index 0000000..85ef522 --- /dev/null +++ b/sdk/edgeconnect/v2/cloudlet.go @@ -0,0 +1,271 @@ +// 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/sdk/internal/http" +) + +// CreateCloudlet creates a new cloudlet in the specified region +// Maps to POST /auth/ctrl/CreateCloudlet +func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/CreateCloudlet" + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("CreateCloudlet failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.handleErrorResponse(resp, "CreateCloudlet") + } + + c.logf("CreateCloudlet: %s/%s created successfully", + input.Cloudlet.Key.Organization, input.Cloudlet.Key.Name) + + return nil +} + +// ShowCloudlet retrieves a single cloudlet by key and region +// Maps to POST /auth/ctrl/ShowCloudlet +func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) (Cloudlet, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet" + + filter := CloudletFilter{ + Cloudlet: Cloudlet{Key: cloudletKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", + cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) + } + + if resp.StatusCode >= 400 { + return Cloudlet{}, c.handleErrorResponse(resp, "ShowCloudlet") + } + + // Parse streaming JSON response + var cloudlets []Cloudlet + if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil { + return Cloudlet{}, fmt.Errorf("ShowCloudlet failed to parse response: %w", err) + } + + if len(cloudlets) == 0 { + return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", + cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) + } + + return cloudlets[0], nil +} + +// ShowCloudlets retrieves all cloudlets matching the filter criteria +// Maps to POST /auth/ctrl/ShowCloudlet +func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, region string) ([]Cloudlet, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet" + + filter := CloudletFilter{ + Cloudlet: Cloudlet{Key: cloudletKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return nil, fmt.Errorf("ShowCloudlets failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { + return nil, c.handleErrorResponse(resp, "ShowCloudlets") + } + + var cloudlets []Cloudlet + if resp.StatusCode == http.StatusNotFound { + return cloudlets, nil // Return empty slice for not found + } + + if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil { + return nil, fmt.Errorf("ShowCloudlets failed to parse response: %w", err) + } + + c.logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets)) + return cloudlets, nil +} + +// DeleteCloudlet removes a cloudlet from the specified region +// Maps to POST /auth/ctrl/DeleteCloudlet +func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/DeleteCloudlet" + + filter := CloudletFilter{ + Cloudlet: Cloudlet{Key: cloudletKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return fmt.Errorf("DeleteCloudlet failed: %w", err) + } + defer resp.Body.Close() + + // 404 is acceptable for delete operations (already deleted) + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { + return c.handleErrorResponse(resp, "DeleteCloudlet") + } + + c.logf("DeleteCloudlet: %s/%s deleted successfully", + cloudletKey.Organization, cloudletKey.Name) + + return nil +} + +// GetCloudletManifest retrieves the deployment manifest for a cloudlet +// Maps to POST /auth/ctrl/GetCloudletManifest +func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletManifest, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletManifest" + + filter := CloudletFilter{ + Cloudlet: Cloudlet{Key: cloudletKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return nil, fmt.Errorf("GetCloudletManifest failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w", + cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) + } + + if resp.StatusCode >= 400 { + return nil, c.handleErrorResponse(resp, "GetCloudletManifest") + } + + // Parse the response as CloudletManifest + var manifest CloudletManifest + if err := c.parseDirectJSONResponse(resp, &manifest); err != nil { + return nil, fmt.Errorf("GetCloudletManifest failed to parse response: %w", err) + } + + c.logf("GetCloudletManifest: retrieved manifest for %s/%s", + cloudletKey.Organization, cloudletKey.Name) + + return &manifest, nil +} + +// GetCloudletResourceUsage retrieves resource usage information for a cloudlet +// Maps to POST /auth/ctrl/GetCloudletResourceUsage +func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletResourceUsage, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletResourceUsage" + + filter := CloudletFilter{ + Cloudlet: Cloudlet{Key: cloudletKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w", + cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) + } + + if resp.StatusCode >= 400 { + return nil, c.handleErrorResponse(resp, "GetCloudletResourceUsage") + } + + // Parse the response as CloudletResourceUsage + var usage CloudletResourceUsage + if err := c.parseDirectJSONResponse(resp, &usage); err != nil { + return nil, fmt.Errorf("GetCloudletResourceUsage failed to parse response: %w", err) + } + + c.logf("GetCloudletResourceUsage: retrieved usage for %s/%s", + cloudletKey.Organization, cloudletKey.Name) + + return &usage, nil +} + +// parseStreamingCloudletResponse parses the EdgeXR streaming JSON response format for cloudlets +func (c *Client) parseStreamingCloudletResponse(resp *http.Response, result interface{}) error { + var responses []Response[Cloudlet] + + parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { + var response Response[Cloudlet] + if err := json.Unmarshal(line, &response); err != nil { + return err + } + responses = append(responses, response) + return nil + }) + + if parseErr != nil { + return parseErr + } + + // Extract data from responses + var cloudlets []Cloudlet + var messages []string + + for _, response := range responses { + if response.HasData() { + cloudlets = append(cloudlets, response.Data) + } + if response.IsMessage() { + messages = append(messages, response.Data.GetMessage()) + } + } + + // If we have error messages, return them + if len(messages) > 0 { + return &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + } + } + + // Set result based on type + switch v := result.(type) { + case *[]Cloudlet: + *v = cloudlets + default: + return fmt.Errorf("unsupported result type: %T", result) + } + + return nil +} + +// parseDirectJSONResponse parses a direct JSON response (not streaming) +func (c *Client) parseDirectJSONResponse(resp *http.Response, result interface{}) error { + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(result); err != nil { + return fmt.Errorf("failed to decode JSON response: %w", err) + } + return nil +} diff --git a/sdk/edgeconnect/v2/cloudlet_test.go b/sdk/edgeconnect/v2/cloudlet_test.go new file mode 100644 index 0000000..8f2cc06 --- /dev/null +++ b/sdk/edgeconnect/v2/cloudlet_test.go @@ -0,0 +1,408 @@ +// 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") + } + }) + } +} diff --git a/sdk/edgeconnect/v2/types.go b/sdk/edgeconnect/v2/types.go new file mode 100644 index 0000000..82995e0 --- /dev/null +++ b/sdk/edgeconnect/v2/types.go @@ -0,0 +1,407 @@ +// 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 { + Key AppKey `json:"key"` + Region string `json:"region"` +} + +// DeleteAppInstanceInput represents input for deleting an app instance +type DeleteAppInstanceInput struct { + Key AppInstanceKey `json:"key"` +} + +// Response wrapper types + +// Response wraps a single API response +type Response[T Message] struct { + 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:"-"` +} + +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()) + } + } + return messages +} + +func (r *Responses[T]) IsSuccessful() bool { + return 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"` +} diff --git a/sdk/examples/comprehensive/main.go b/sdk/examples/comprehensive/main.go index 616279f..d3fb922 100644 --- a/sdk/examples/comprehensive/main.go +++ b/sdk/examples/comprehensive/main.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ) func main() { @@ -24,20 +24,20 @@ func main() { username := getEnvOrDefault("EDGEXR_USERNAME", "") password := getEnvOrDefault("EDGEXR_PASSWORD", "") - var client *edgeconnect.Client + var client *v2.Client if token != "" { fmt.Println("🔐 Using Bearer token authentication") - client = edgeconnect.NewClient(baseURL, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), - edgeconnect.WithLogger(log.Default()), + client = v2.NewClient(baseURL, + v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + v2.WithAuthProvider(v2.NewStaticTokenProvider(token)), + v2.WithLogger(log.Default()), ) } else if username != "" && password != "" { fmt.Println("🔐 Using username/password authentication") - client = edgeconnect.NewClientWithCredentials(baseURL, username, password, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithLogger(log.Default()), + client = v2.NewClientWithCredentials(baseURL, username, password, + v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + v2.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 *edgeconnect.Client, config WorkflowConfig) error { +func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config WorkflowConfig) error { fmt.Println("═══ Phase 1: Application Management ═══") // 1. Create Application fmt.Println("\n1️⃣ Creating application...") - app := &edgeconnect.NewAppInput{ + app := &v2.NewAppInput{ Region: config.Region, - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ + App: v2.App{ + Key: v2.AppKey{ Organization: config.Organization, Name: config.AppName, Version: config.AppVersion, @@ -101,10 +101,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Deployment: "kubernetes", ImageType: "ImageTypeDocker", // field is ignored ImagePath: "https://registry-1.docker.io/library/nginx:latest", // must be set. Even for kubernetes - DefaultFlavor: edgeconnect.Flavor{Name: config.FlavorName}, + DefaultFlavor: v2.Flavor{Name: config.FlavorName}, ServerlessConfig: struct{}{}, // must be set AllowServerless: true, // must be set to true for kubernetes - RequiredOutboundConnections: []edgeconnect.SecurityRule{ + RequiredOutboundConnections: []v2.SecurityRule{ { Protocol: "tcp", PortRangeMin: 80, @@ -128,7 +128,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 2. Show Application Details fmt.Println("\n2️⃣ Querying application details...") - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: config.Organization, Name: config.AppName, Version: config.AppVersion, @@ -146,7 +146,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 3. List Applications in Organization fmt.Println("\n3️⃣ Listing applications in organization...") - filter := edgeconnect.AppKey{Organization: config.Organization} + filter := v2.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 *edgeconnect.Client, config // 4. Create Application Instance fmt.Println("\n4️⃣ Creating application instance...") - instance := &edgeconnect.NewAppInstanceInput{ + instance := &v2.NewAppInstanceInput{ Region: config.Region, - AppInst: edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ + AppInst: v2.AppInstance{ + Key: v2.AppInstanceKey{ Organization: config.Organization, Name: config.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: config.CloudletOrg, Name: config.CloudletName, }, }, AppKey: appKey, - Flavor: edgeconnect.Flavor{Name: config.FlavorName}, + Flavor: v2.Flavor{Name: config.FlavorName}, }, } @@ -184,10 +184,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 5. Wait for Application Instance to be Ready fmt.Println("\n5️⃣ Waiting for application instance to be ready...") - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: config.Organization, Name: config.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: config.CloudletOrg, Name: config.CloudletName, }, @@ -207,7 +207,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 6. List Application Instances fmt.Println("\n6️⃣ Listing application instances...") - instances, err := c.ShowAppInstances(ctx, edgeconnect.AppInstanceKey{Organization: config.Organization}, config.Region) + instances, err := c.ShowAppInstances(ctx, v2.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 *edgeconnect.Client, config // 8. Show Cloudlet Details fmt.Println("\n8️⃣ Querying cloudlet information...") - cloudletKey := edgeconnect.CloudletKey{ + cloudletKey := v2.CloudletKey{ Organization: config.CloudletOrg, Name: config.CloudletName, } @@ -287,7 +287,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 13. Verify Cleanup fmt.Println("\n1️⃣3️⃣ Verifying cleanup...") _, err = c.ShowApp(ctx, appKey, config.Region) - if err != nil && fmt.Sprintf("%v", err) == edgeconnect.ErrResourceNotFound.Error() { + if err != nil && fmt.Sprintf("%v", err) == v2.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 *edgeconnect.Client, instanceKey edgeconnect.AppInstanceKey, region string, timeout time.Duration) (edgeconnect.AppInstance, error) { +func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppInstanceKey, region string, timeout time.Duration) (v2.AppInstance, error) { timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -318,7 +318,7 @@ func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKe for { select { case <-timeoutCtx.Done(): - return edgeconnect.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout) + return v2.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout) case <-ticker.C: instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, region) diff --git a/sdk/examples/deploy_app.go b/sdk/examples/deploy_app.go index b413886..84297dc 100644 --- a/sdk/examples/deploy_app.go +++ b/sdk/examples/deploy_app.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ) func main() { @@ -24,22 +24,22 @@ func main() { username := getEnvOrDefault("EDGEXR_USERNAME", "") password := getEnvOrDefault("EDGEXR_PASSWORD", "") - var edgeClient *edgeconnect.Client + var edgeClient *v2.Client if token != "" { // Use static token authentication fmt.Println("🔐 Using Bearer token authentication") - edgeClient = edgeconnect.NewClient(baseURL, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), - edgeconnect.WithLogger(log.Default()), + edgeClient = v2.NewClient(baseURL, + v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + v2.WithAuthProvider(v2.NewStaticTokenProvider(token)), + v2.WithLogger(log.Default()), ) } else if username != "" && password != "" { // Use username/password authentication (matches existing client pattern) fmt.Println("🔐 Using username/password authentication") - edgeClient = edgeconnect.NewClientWithCredentials(baseURL, username, password, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithLogger(log.Default()), + edgeClient = v2.NewClientWithCredentials(baseURL, username, password, + v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + v2.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 := &edgeconnect.NewAppInput{ + app := &v2.NewAppInput{ Region: "EU", - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ + App: v2.App{ + Key: v2.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: edgeconnect.Flavor{Name: "EU.small"}, + DefaultFlavor: v2.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 *edgeconnect.Client, input *edgeconnect.NewAppInput) error { +func demonstrateAppLifecycle(ctx context.Context, edgeClient *v2.Client, input *v2.NewAppInput) error { appKey := input.App.Key region := input.Region @@ -98,7 +98,7 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client // Step 3: List applications in the organization fmt.Println("\n3. Listing applications...") - filter := edgeconnect.AppKey{Organization: appKey.Organization} + filter := v2.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 *edgeconnect.Client fmt.Println("\n5. Verifying deletion...") _, err = edgeClient.ShowApp(ctx, appKey, region) if err != nil { - if strings.Contains(fmt.Sprintf("%v", err), edgeconnect.ErrResourceNotFound.Error()) { + if strings.Contains(fmt.Sprintf("%v", err), v2.ErrResourceNotFound.Error()) { fmt.Printf("✅ App successfully deleted (not found)\n") } else { return fmt.Errorf("unexpected error verifying deletion: %w", err) From 2a8e99eb6366420ad5a499dd95f854dee3c83eac Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Mon, 20 Oct 2025 13:41:50 +0200 Subject: [PATCH 03/21] feat(config): add API version selector for v1 and v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add configurable API version selection with three methods: - Config file: api_version: "v1" or "v2" in .edge-connect.yaml - CLI flag: --api-version v1/v2 - Environment variable: EDGE_CONNECT_API_VERSION=v1/v2 Changes: - Update root.go to add api_version config and env var support - Update app.go and instance.go to support both v1 and v2 clients - Add example config file with api_version documentation - Default to v2 for backward compatibility - Apply command always uses v2 (advanced feature) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .edge-connect.yaml.example | 14 +++ cmd/app.go | 190 ++++++++++++++++++++++++++-------- cmd/apply.go | 4 +- cmd/instance.go | 205 ++++++++++++++++++++++++++----------- cmd/root.go | 14 ++- 5 files changed, 319 insertions(+), 108 deletions(-) create mode 100644 .edge-connect.yaml.example diff --git a/.edge-connect.yaml.example b/.edge-connect.yaml.example new file mode 100644 index 0000000..694ed1e --- /dev/null +++ b/.edge-connect.yaml.example @@ -0,0 +1,14 @@ +# 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" diff --git a/cmd/app.go b/cmd/app.go index a96f599..79fc2c5 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -7,8 +7,10 @@ import ( "net/http" "net/url" "os" + "strings" "time" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -50,7 +52,45 @@ func validateBaseURL(baseURL string) error { return nil } -func newSDKClient() *v2.Client { +func getAPIVersion() string { + version := viper.GetString("api_version") + if version == "" { + version = "v2" // default to v2 + } + return strings.ToLower(version) +} + +func newSDKClientV1() *edgeconnect.Client { + baseURL := viper.GetString("base_url") + username := viper.GetString("username") + password := viper.GetString("password") + + err := validateBaseURL(baseURL) + if err != nil { + fmt.Printf("Error parsing baseURL: '%s' with error: %s\n", baseURL, err.Error()) + os.Exit(1) + } + + // Build options + opts := []edgeconnect.Option{ + 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") @@ -90,19 +130,37 @@ var createAppCmd = &cobra.Command{ Use: "create", Short: "Create a new Edge Connect application", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - input := &v2.NewAppInput{ - Region: region, - App: v2.App{ - Key: v2.AppKey{ - Organization: organization, - Name: appName, - Version: appVersion, + 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, + }, }, - }, + } + 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) @@ -115,19 +173,35 @@ var showAppCmd = &cobra.Command{ Use: "show", Short: "Show details of an Edge Connect application", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - appKey := v2.AppKey{ - Organization: organization, - Name: appName, - Version: appVersion, - } + apiVersion := getAPIVersion() - app, err := c.ShowApp(context.Background(), appKey, region) - if err != nil { - fmt.Printf("Error showing app: %v\n", err) - os.Exit(1) + 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) } - fmt.Printf("Application details:\n%+v\n", app) }, } @@ -135,21 +209,40 @@ var listAppsCmd = &cobra.Command{ Use: "list", Short: "List Edge Connect applications", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - appKey := v2.AppKey{ - Organization: organization, - Name: appName, - Version: appVersion, - } + apiVersion := getAPIVersion() - 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) + 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) + } } }, } @@ -158,14 +251,27 @@ var deleteAppCmd = &cobra.Command{ Use: "delete", Short: "Delete an Edge Connect application", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - appKey := v2.AppKey{ - Organization: organization, - Name: appName, - Version: appVersion, + 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) } - err := c.DeleteApp(context.Background(), appKey, region) if err != nil { fmt.Printf("Error deleting app: %v\n", err) os.Exit(1) diff --git a/cmd/apply.go b/cmd/apply.go index 41e94e9..311f64b 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -67,8 +67,8 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name) - // Step 3: Create EdgeConnect client - client := newSDKClient() + // Step 3: Create EdgeConnect client (apply always uses v2) + client := newSDKClientV2() // Step 4: Create deployment planner planner := apply.NewPlanner(client) diff --git a/cmd/instance.go b/cmd/instance.go index 30194ab..1eb6cb6 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" "github.com/spf13/cobra" ) @@ -26,30 +27,59 @@ var createInstanceCmd = &cobra.Command{ Use: "create", Short: "Create a new Edge Connect application instance", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - input := &v2.NewAppInstanceInput{ - Region: region, - AppInst: v2.AppInstance{ - Key: v2.AppInstanceKey{ - Organization: organization, - Name: instanceName, - CloudletKey: v2.CloudletKey{ - Organization: cloudletOrg, - Name: cloudletName, + 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, }, }, - AppKey: v2.AppKey{ - Organization: organization, - Name: appName, - Version: appVersion, + } + 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, + }, }, - Flavor: v2.Flavor{ - Name: flavorName, - }, - }, + } + err = c.CreateAppInstance(context.Background(), input) } - err := c.CreateAppInstance(context.Background(), input) if err != nil { fmt.Printf("Error creating app instance: %v\n", err) os.Exit(1) @@ -62,22 +92,41 @@ var showInstanceCmd = &cobra.Command{ Use: "show", Short: "Show details of an Edge Connect application instance", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - instanceKey := v2.AppInstanceKey{ - Organization: organization, - Name: instanceName, - CloudletKey: v2.CloudletKey{ - Organization: cloudletOrg, - Name: cloudletName, - }, - } + apiVersion := getAPIVersion() - instance, err := c.ShowAppInstance(context.Background(), instanceKey, region) - if err != nil { - fmt.Printf("Error showing app instance: %v\n", err) - os.Exit(1) + if apiVersion == "v1" { + c := newSDKClientV1() + 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) + } else { + c := newSDKClientV2() + instanceKey := v2.AppInstanceKey{ + Organization: organization, + Name: instanceName, + CloudletKey: v2.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) } - fmt.Printf("Application instance details:\n%+v\n", instance) }, } @@ -85,24 +134,46 @@ var listInstancesCmd = &cobra.Command{ Use: "list", Short: "List Edge Connect application instances", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - instanceKey := v2.AppInstanceKey{ - Organization: organization, - Name: instanceName, - CloudletKey: v2.CloudletKey{ - Organization: cloudletOrg, - Name: cloudletName, - }, - } + apiVersion := getAPIVersion() - 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) + if apiVersion == "v1" { + c := newSDKClientV1() + instanceKey := edgeconnect.AppInstanceKey{ + Organization: organization, + Name: instanceName, + CloudletKey: edgeconnect.CloudletKey{ + Organization: cloudletOrg, + Name: cloudletName, + }, + } + 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) + } + } else { + c := newSDKClientV2() + instanceKey := v2.AppInstanceKey{ + Organization: organization, + Name: instanceName, + CloudletKey: v2.CloudletKey{ + Organization: cloudletOrg, + Name: cloudletName, + }, + } + 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) + } } }, } @@ -111,17 +182,33 @@ var deleteInstanceCmd = &cobra.Command{ Use: "delete", Short: "Delete an Edge Connect application instance", Run: func(cmd *cobra.Command, args []string) { - c := newSDKClient() - instanceKey := v2.AppInstanceKey{ - Organization: organization, - Name: instanceName, - CloudletKey: v2.CloudletKey{ - Organization: cloudletOrg, - Name: cloudletName, - }, + 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) } - err := c.DeleteAppInstance(context.Background(), instanceKey, region) if err != nil { fmt.Printf("Error deleting app instance: %v\n", err) os.Exit(1) diff --git a/cmd/root.go b/cmd/root.go index 6fa2dd6..dd22f72 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,11 +9,12 @@ import ( ) var ( - cfgFile string - baseURL string - username string - password string - debug bool + cfgFile string + baseURL string + username string + password string + debug bool + apiVersion string ) // rootCmd represents the base command when called without any subcommands @@ -40,11 +41,13 @@ 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") viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")) viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")) viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")) + viper.BindPFlag("api_version", rootCmd.PersistentFlags().Lookup("api-version")) } func initConfig() { @@ -53,6 +56,7 @@ func initConfig() { viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL") viper.BindEnv("username", "EDGE_CONNECT_USERNAME") viper.BindEnv("password", "EDGE_CONNECT_PASSWORD") + viper.BindEnv("api_version", "EDGE_CONNECT_API_VERSION") if cfgFile != "" { viper.SetConfigFile(cfgFile) From 59ba5ffb02661572067adf52fe8f669f34c5d3b3 Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Mon, 20 Oct 2025 13:49:09 +0200 Subject: [PATCH 04/21] fix(apply): add validation to reject v1 API version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The apply command requires v2 API features and cannot work with v1. Add early validation to provide a clear error message when users try to use apply with --api-version v1, instead of failing with a cryptic 403 Forbidden error. Error message explains that apply only supports v2 and guides users to use --api-version v2 or remove the api_version setting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/apply.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index 311f64b..3a50ddf 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -67,13 +67,19 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name) - // Step 3: Create EdgeConnect client (apply always uses v2) + // Step 3: Validate API version (apply only supports v2) + apiVersion := getAPIVersion() + if apiVersion == "v1" { + return fmt.Errorf("apply command only supports API v2. The v1 API does not support the advanced deployment features required by this command. Please use --api-version v2 or remove the api_version setting") + } + + // Step 4: Create EdgeConnect client (v2 only) client := newSDKClientV2() - // Step 4: Create deployment planner + // Step 5: Create deployment planner planner := apply.NewPlanner(client) - // Step 5: Generate deployment plan + // Step 6: Generate deployment plan fmt.Println("🔍 Analyzing current state and generating deployment plan...") planOptions := apply.DefaultPlanOptions() From 98a8c4db4a04b20dec8083a7281d08e1f2a63b46 Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Mon, 20 Oct 2025 13:57:57 +0200 Subject: [PATCH 05/21] feat(apply): add v1 API support to apply command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor apply command to support both v1 and v2 APIs: - Split internal/apply into v1 and v2 subdirectories - v1: Uses sdk/edgeconnect (from revision/v1 branch) - v2: Uses sdk/edgeconnect/v2 - Update cmd/apply.go to route to appropriate version based on api_version config - Both versions now fully functional with their respective API endpoints Changes: - Created internal/apply/v1/ with v1 SDK implementation - Created internal/apply/v2/ with v2 SDK implementation - Updated cmd/apply.go with runApplyV1() and runApplyV2() functions - Removed validation error that rejected v1 - Apply command now respects --api-version flag and config setting Testing: - V1 with edge.platform: ✅ Generates deployment plan correctly - V2 with orca.platform: ✅ Works as before 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/apply.go | 156 ++++- internal/apply/v1/manager.go | 286 ++++++++ internal/apply/v1/manager_test.go | 497 ++++++++++++++ internal/apply/v1/planner.go | 555 ++++++++++++++++ internal/apply/v1/planner_test.go | 663 +++++++++++++++++++ internal/apply/{ => v1}/strategy.go | 2 +- internal/apply/v1/strategy_recreate.go | 548 +++++++++++++++ internal/apply/v1/types.go | 462 +++++++++++++ internal/apply/{ => v2}/manager.go | 2 +- internal/apply/{ => v2}/manager_test.go | 2 +- internal/apply/{ => v2}/planner.go | 2 +- internal/apply/{ => v2}/planner_test.go | 2 +- internal/apply/v2/strategy.go | 106 +++ internal/apply/{ => v2}/strategy_recreate.go | 2 +- internal/apply/{ => v2}/types.go | 2 +- 15 files changed, 3265 insertions(+), 22 deletions(-) create mode 100644 internal/apply/v1/manager.go create mode 100644 internal/apply/v1/manager_test.go create mode 100644 internal/apply/v1/planner.go create mode 100644 internal/apply/v1/planner_test.go rename internal/apply/{ => v1}/strategy.go (99%) create mode 100644 internal/apply/v1/strategy_recreate.go create mode 100644 internal/apply/v1/types.go rename internal/apply/{ => v2}/manager.go (99%) rename internal/apply/{ => v2}/manager_test.go (99%) rename internal/apply/{ => v2}/planner.go (99%) rename internal/apply/{ => v2}/planner_test.go (99%) create mode 100644 internal/apply/v2/strategy.go rename internal/apply/{ => v2}/strategy_recreate.go (99%) rename internal/apply/{ => v2}/types.go (99%) diff --git a/cmd/apply.go b/cmd/apply.go index 3a50ddf..1493841 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -10,7 +10,8 @@ import ( "path/filepath" "strings" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply" + applyv1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply/v1" + applyv2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply/v2" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "github.com/spf13/cobra" ) @@ -67,22 +68,27 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name) - // Step 3: Validate API version (apply only supports v2) + // Step 3: Determine API version and create appropriate client apiVersion := getAPIVersion() + + // Step 4-6: Execute deployment based on API version if apiVersion == "v1" { - return fmt.Errorf("apply command only supports API v2. The v1 API does not support the advanced deployment features required by this command. Please use --api-version v2 or remove the api_version setting") + return runApplyV1(cfg, manifestContent, isDryRun, autoApprove) } + return runApplyV2(cfg, manifestContent, isDryRun, autoApprove) +} - // Step 4: Create EdgeConnect client (v2 only) - client := newSDKClientV2() +func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error { + // Create v1 client + client := newSDKClientV1() - // Step 5: Create deployment planner - planner := apply.NewPlanner(client) + // Create deployment planner + planner := applyv1.NewPlanner(client) - // Step 6: Generate deployment plan + // Generate deployment plan fmt.Println("🔍 Analyzing current state and generating deployment plan...") - planOptions := apply.DefaultPlanOptions() + planOptions := applyv1.DefaultPlanOptions() planOptions.DryRun = isDryRun result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions) @@ -90,7 +96,7 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { return fmt.Errorf("failed to generate deployment plan: %w", err) } - // Step 6: Display plan summary + // Display plan summary fmt.Println("\n📋 Deployment Plan:") fmt.Println(strings.Repeat("=", 50)) fmt.Println(result.Plan.Summary) @@ -104,13 +110,13 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { } } - // Step 7: If dry-run, stop here + // If dry-run, stop here if isDryRun { fmt.Println("\n🔍 Dry-run complete. No changes were made.") return nil } - // Step 8: Confirm deployment (in non-dry-run mode) + // Confirm deployment if result.Plan.TotalActions == 0 { fmt.Println("\n✅ No changes needed. Resources are already in desired state.") return nil @@ -124,16 +130,112 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { return nil } - // Step 9: Execute deployment + // Execute deployment fmt.Println("\n🚀 Starting deployment...") - manager := apply.NewResourceManager(client, apply.WithLogger(log.Default())) + manager := applyv1.NewResourceManager(client, applyv1.WithLogger(log.Default())) deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent) if err != nil { return fmt.Errorf("deployment failed: %w", err) } - // Step 10: Display results + // 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) +} + +type deploymentResult interface { + IsSuccess() bool + GetDuration() string + GetCompletedActions() []actionResult + GetFailedActions() []actionResult + GetError() error +} + +type actionResult interface { + GetType() string + GetTarget() string + GetError() error +} + +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 { if deployResult.Success { fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration) if len(deployResult.CompletedActions) > 0 { @@ -155,7 +257,31 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) 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 } diff --git a/internal/apply/v1/manager.go b/internal/apply/v1/manager.go new file mode 100644 index 0000000..a0668e8 --- /dev/null +++ b/internal/apply/v1/manager.go @@ -0,0 +1,286 @@ +// 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 + +import ( + "context" + "fmt" + "time" + + "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 +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{} + + // Rollback completed instances (in reverse order) + 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) + } + } + } + + 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 := edgeconnect.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 := edgeconnect.AppInstanceKey{ + Organization: plan.AppAction.Desired.Organization, + Name: instanceAction.InstanceName, + CloudletKey: edgeconnect.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) +} + +// 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...) + } +} diff --git a/internal/apply/v1/manager_test.go b/internal/apply/v1/manager_test.go new file mode 100644 index 0000000..9ed3cac --- /dev/null +++ b/internal/apply/v1/manager_test.go @@ -0,0 +1,497 @@ +// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios +// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients +package v1 + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// MockResourceClient extends MockEdgeConnectClient with resource management methods +type MockResourceClient struct { + MockEdgeConnectClient +} + +func (m *MockResourceClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { + args := m.Called(ctx, input) + return args.Error(0) +} + +func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error { + args := m.Called(ctx, input) + return args.Error(0) +} + +func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error { + args := m.Called(ctx, appKey, region) + return args.Error(0) +} + +func (m *MockResourceClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error { + args := m.Called(ctx, input) + return args.Error(0) +} + +func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error { + args := m.Called(ctx, input) + return args.Error(0) +} + +func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.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("*edgeconnect.NewAppInput")). + Return(nil) + mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.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("*edgeconnect.NewAppInput")). + Return(&edgeconnect.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("*edgeconnect.NewAppInput")). + Return(nil) + mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). + Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}}) + + // Mock rollback operations + mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.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("*edgeconnect.NewAppInput")). + Return(nil) + mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.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("edgeconnect.AppInstanceKey"), "US"). + Return(nil) + mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.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("edgeconnect.AppKey"), "US"). + Return(&edgeconnect.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 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) +} diff --git a/internal/apply/v1/planner.go b/internal/apply/v1/planner.go new file mode 100644 index 0000000..33b8d9c --- /dev/null +++ b/internal/apply/v1/planner.go @@ -0,0 +1,555 @@ +// 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 + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "os" + "strings" + "time" + + "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 +type EdgeConnectClientInterface interface { + ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) + CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error + UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error + DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error + ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) + CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error + UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error + DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error +} + +// Planner defines the interface for deployment planning +type Planner interface { + // Plan analyzes the configuration and current state to generate a deployment plan + 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 := edgeconnect.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{ + Protocol: conn.Protocol, + PortRangeMin: conn.PortRangeMin, + PortRangeMax: conn.PortRangeMax, + RemoteCIDR: conn.RemoteCIDR, + } + } + + 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 := edgeconnect.AppInstanceKey{ + Organization: desired.Organization, + Name: desired.Name, + CloudletKey: edgeconnect.CloudletKey{ + Organization: desired.CloudletOrg, + Name: desired.CloudletName, + }, + } + + instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, 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 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 + if plan.AppAction.Type == ActionCreate { + duration += 30 * time.Second + } 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 { + if action.Type == ActionCreate { + instanceDuration = max(instanceDuration, 2*time.Minute) + } else if action.Type == 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) +} diff --git a/internal/apply/v1/planner_test.go b/internal/apply/v1/planner_test.go new file mode 100644 index 0000000..8c1e48a --- /dev/null +++ b/internal/apply/v1/planner_test.go @@ -0,0 +1,663 @@ +// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios +// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios +package v1 + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// MockEdgeConnectClient is a mock implementation of the EdgeConnect client +type MockEdgeConnectClient struct { + mock.Mock +} + +func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) { + args := m.Called(ctx, appKey, region) + if args.Get(0) == nil { + return edgeconnect.App{}, args.Error(1) + } + return args.Get(0).(edgeconnect.App), args.Error(1) +} + +func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) { + args := m.Called(ctx, instanceKey, region) + if args.Get(0) == nil { + return edgeconnect.AppInstance{}, args.Error(1) + } + return args.Get(0).(edgeconnect.AppInstance), args.Error(1) +} + +func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { + args := m.Called(ctx, input) + return args.Error(0) +} + +func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error { + args := m.Called(ctx, input) + return args.Error(0) +} + +func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error { + args := m.Called(ctx, appKey, region) + return args.Error(0) +} + +func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error { + args := m.Called(ctx, input) + return args.Error(0) +} + +func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error { + args := m.Called(ctx, input) + return args.Error(0) +} + +func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error { + args := m.Called(ctx, instanceKey, region) + return args.Error(0) +} + +func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect.AppKey, region string) ([]edgeconnect.App, error) { + args := m.Called(ctx, appKey, region) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]edgeconnect.App), args.Error(1) +} + +func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) { + args := m.Called(ctx, instanceKey, region) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]edgeconnect.AppInstance), args.Error(1) +} + +func TestNewPlanner(t *testing.T) { + mockClient := &MockEdgeConnectClient{} + planner := NewPlanner(mockClient) + + assert.NotNil(t, planner) + assert.IsType(t, &EdgeConnectPlanner{}, planner) +} + +func TestDefaultPlanOptions(t *testing.T) { + opts := DefaultPlanOptions() + + assert.False(t, opts.DryRun) + assert.False(t, opts.Force) + assert.False(t, opts.SkipStateCheck) + assert.True(t, opts.ParallelQueries) + assert.Equal(t, 30*time.Second, opts.Timeout) +} + +func createTestConfig(t *testing.T) *config.EdgeConnectConfig { + // Create temporary manifest file + tempDir := t.TempDir() + manifestFile := filepath.Join(tempDir, "test-manifest.yaml") + manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" + err := os.WriteFile(manifestFile, []byte(manifestContent), 0644) + require.NoError(t, err) + + return &config.EdgeConnectConfig{ + Kind: "edgeconnect-deployment", + Metadata: config.Metadata{ + Name: "test-app", + AppVersion: "1.0.0", + Organization: "testorg", + }, + Spec: config.Spec{ + K8sApp: &config.K8sApp{ + ManifestFile: manifestFile, + }, + InfraTemplate: []config.InfraTemplate{ + { + Region: "US", + CloudletOrg: "TestCloudletOrg", + CloudletName: "TestCloudlet", + FlavorName: "small", + }, + }, + Network: &config.NetworkConfig{ + OutboundConnections: []config.OutboundConnection{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + }, + }, + }, + } +} + +func TestPlanNewDeployment(t *testing.T) { + mockClient := &MockEdgeConnectClient{} + planner := NewPlanner(mockClient) + testConfig := createTestConfig(t) + + // Mock API calls to return "not found" errors + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). + Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) + + mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). + Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) + + ctx := context.Background() + result, err := planner.Plan(ctx, testConfig) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Plan) + require.NoError(t, result.Error) + + plan := result.Plan + assert.Equal(t, "test-app", plan.ConfigName) + assert.Equal(t, ActionCreate, plan.AppAction.Type) + assert.Equal(t, "Application does not exist", plan.AppAction.Reason) + + require.Len(t, plan.InstanceActions, 1) + assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type) + assert.Equal(t, "Instance does not exist", plan.InstanceActions[0].Reason) + + assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance + assert.False(t, plan.IsEmpty()) + + mockClient.AssertExpectations(t) +} + +func TestPlanExistingDeploymentNoChanges(t *testing.T) { + mockClient := &MockEdgeConnectClient{} + planner := NewPlanner(mockClient) + testConfig := createTestConfig(t) + + // Note: We would calculate expected manifest hash here when API supports it + + // Mock existing app with same manifest hash and outbound connections + manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" + existingApp := &edgeconnect.App{ + Key: edgeconnect.AppKey{ + Organization: "testorg", + Name: "test-app", + Version: "1.0.0", + }, + Deployment: "kubernetes", + DeploymentManifest: manifestContent, + RequiredOutboundConnections: []edgeconnect.SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + }, + // Note: Manifest hash tracking would be implemented when API supports annotations + } + + // Mock existing instance + existingInstance := &edgeconnect.AppInstance{ + Key: edgeconnect.AppInstanceKey{ + Organization: "testorg", + Name: "test-app-1.0.0-instance", + CloudletKey: edgeconnect.CloudletKey{ + Organization: "TestCloudletOrg", + Name: "TestCloudlet", + }, + }, + AppKey: edgeconnect.AppKey{ + Organization: "testorg", + Name: "test-app", + Version: "1.0.0", + }, + Flavor: edgeconnect.Flavor{ + Name: "small", + }, + State: "Ready", + PowerState: "PowerOn", + } + + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). + Return(*existingApp, nil) + + mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). + Return(*existingInstance, nil) + + ctx := context.Background() + result, err := planner.Plan(ctx, testConfig) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Plan) + + plan := result.Plan + assert.Equal(t, ActionNone, plan.AppAction.Type) + assert.Len(t, plan.InstanceActions, 1) + assert.Equal(t, ActionNone, plan.InstanceActions[0].Type) + assert.Equal(t, 0, plan.TotalActions) + assert.True(t, plan.IsEmpty()) + assert.Contains(t, plan.Summary, "No changes required") + + mockClient.AssertExpectations(t) +} + +func TestPlanWithOptions(t *testing.T) { + mockClient := &MockEdgeConnectClient{} + planner := NewPlanner(mockClient) + testConfig := createTestConfig(t) + + opts := PlanOptions{ + DryRun: true, + SkipStateCheck: true, + Timeout: 10 * time.Second, + } + + ctx := context.Background() + result, err := planner.PlanWithOptions(ctx, testConfig, opts) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Plan) + + plan := result.Plan + assert.True(t, plan.DryRun) + assert.Equal(t, ActionCreate, plan.AppAction.Type) + assert.Contains(t, plan.AppAction.Reason, "state check skipped") + + // No API calls should be made when SkipStateCheck is true + mockClient.AssertNotCalled(t, "ShowApp") + mockClient.AssertNotCalled(t, "ShowAppInstance") +} + +func TestPlanMultipleInfrastructures(t *testing.T) { + mockClient := &MockEdgeConnectClient{} + planner := NewPlanner(mockClient) + testConfig := createTestConfig(t) + + // Add a second infrastructure target + testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{ + Region: "EU", + CloudletOrg: "EUCloudletOrg", + CloudletName: "EUCloudlet", + FlavorName: "medium", + }) + + // Mock API calls to return "not found" errors + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). + Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) + + mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). + Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) + + mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "EU"). + Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) + + ctx := context.Background() + result, err := planner.Plan(ctx, testConfig) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Plan) + + plan := result.Plan + assert.Equal(t, ActionCreate, plan.AppAction.Type) + + // Should have 2 instance actions, one for each infrastructure + require.Len(t, plan.InstanceActions, 2) + assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type) + assert.Equal(t, ActionCreate, plan.InstanceActions[1].Type) + + assert.Equal(t, 3, plan.TotalActions) // 1 app + 2 instances + + // Test cloudlet and region aggregation + cloudlets := plan.GetTargetCloudlets() + regions := plan.GetTargetRegions() + assert.Len(t, cloudlets, 2) + assert.Len(t, regions, 2) + + mockClient.AssertExpectations(t) +} + +func TestCalculateManifestHash(t *testing.T) { + planner := &EdgeConnectPlanner{} + tempDir := t.TempDir() + + // Create test file + testFile := filepath.Join(tempDir, "test.yaml") + content := "test content for hashing" + err := os.WriteFile(testFile, []byte(content), 0644) + require.NoError(t, err) + + hash1, err := planner.calculateManifestHash(testFile) + require.NoError(t, err) + assert.NotEmpty(t, hash1) + assert.Len(t, hash1, 64) // SHA256 hex string length + + // Same content should produce same hash + hash2, err := planner.calculateManifestHash(testFile) + require.NoError(t, err) + assert.Equal(t, hash1, hash2) + + // Different content should produce different hash + err = os.WriteFile(testFile, []byte("different content"), 0644) + require.NoError(t, err) + + hash3, err := planner.calculateManifestHash(testFile) + require.NoError(t, err) + assert.NotEqual(t, hash1, hash3) + + // Empty file path should return empty hash + hash4, err := planner.calculateManifestHash("") + require.NoError(t, err) + assert.Empty(t, hash4) + + // Non-existent file should return error + _, err = planner.calculateManifestHash("/non/existent/file") + assert.Error(t, err) +} + +func TestCompareAppStates(t *testing.T) { + planner := &EdgeConnectPlanner{} + + current := &AppState{ + Name: "test-app", + Version: "1.0.0", + AppType: AppTypeK8s, + ManifestHash: "old-hash", + } + + desired := &AppState{ + Name: "test-app", + Version: "1.0.0", + AppType: AppTypeK8s, + ManifestHash: "new-hash", + } + + changes, manifestChanged := planner.compareAppStates(current, desired) + assert.Len(t, changes, 1) + assert.True(t, manifestChanged) + assert.Contains(t, changes[0], "Manifest hash changed") + + // Test no changes + desired.ManifestHash = "old-hash" + changes, manifestChanged = planner.compareAppStates(current, desired) + assert.Empty(t, changes) + assert.False(t, manifestChanged) + + // Test app type change + desired.AppType = AppTypeDocker + changes, manifestChanged = planner.compareAppStates(current, desired) + assert.Len(t, changes, 1) + assert.False(t, manifestChanged) + assert.Contains(t, changes[0], "App type changed") +} + +func TestCompareAppStatesOutboundConnections(t *testing.T) { + planner := &EdgeConnectPlanner{} + + // Test with no outbound connections + current := &AppState{ + Name: "test-app", + Version: "1.0.0", + AppType: AppTypeK8s, + OutboundConnections: nil, + } + + desired := &AppState{ + Name: "test-app", + Version: "1.0.0", + AppType: AppTypeK8s, + OutboundConnections: nil, + } + + changes, _ := planner.compareAppStates(current, desired) + assert.Empty(t, changes, "No changes expected when both have no outbound connections") + + // Test adding outbound connections + desired.OutboundConnections = []SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + } + + changes, _ = planner.compareAppStates(current, desired) + assert.Len(t, changes, 1) + assert.Contains(t, changes[0], "Outbound connections changed") + + // Test identical outbound connections + current.OutboundConnections = []SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + } + + changes, _ = planner.compareAppStates(current, desired) + assert.Empty(t, changes, "No changes expected when outbound connections are identical") + + // Test different outbound connections (different port) + desired.OutboundConnections[0].PortRangeMin = 443 + desired.OutboundConnections[0].PortRangeMax = 443 + + changes, _ = planner.compareAppStates(current, desired) + assert.Len(t, changes, 1) + assert.Contains(t, changes[0], "Outbound connections changed") + + // Test same connections but different order (should be considered equal) + current.OutboundConnections = []SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + { + Protocol: "tcp", + PortRangeMin: 443, + PortRangeMax: 443, + RemoteCIDR: "0.0.0.0/0", + }, + } + + desired.OutboundConnections = []SecurityRule{ + { + Protocol: "tcp", + PortRangeMin: 443, + PortRangeMax: 443, + RemoteCIDR: "0.0.0.0/0", + }, + { + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteCIDR: "0.0.0.0/0", + }, + } + + changes, _ = planner.compareAppStates(current, desired) + assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order") + + // Test removing outbound connections + desired.OutboundConnections = nil + + changes, _ = planner.compareAppStates(current, desired) + assert.Len(t, changes, 1) + assert.Contains(t, changes[0], "Outbound connections changed") +} + +func TestCompareInstanceStates(t *testing.T) { + planner := &EdgeConnectPlanner{} + + current := &InstanceState{ + Name: "test-instance", + FlavorName: "small", + CloudletName: "oldcloudlet", + CloudletOrg: "oldorg", + } + + desired := &InstanceState{ + Name: "test-instance", + FlavorName: "medium", + CloudletName: "newcloudlet", + CloudletOrg: "neworg", + } + + changes := planner.compareInstanceStates(current, desired) + assert.Len(t, changes, 3) + assert.Contains(t, changes[0], "Flavor changed") + assert.Contains(t, changes[1], "Cloudlet changed") + assert.Contains(t, changes[2], "Cloudlet org changed") + + // Test no changes + desired.FlavorName = "small" + desired.CloudletName = "oldcloudlet" + desired.CloudletOrg = "oldorg" + changes = planner.compareInstanceStates(current, desired) + assert.Empty(t, changes) +} + +func TestDeploymentPlanMethods(t *testing.T) { + plan := &DeploymentPlan{ + ConfigName: "test-plan", + AppAction: AppAction{ + Type: ActionCreate, + Desired: &AppState{Name: "test-app"}, + }, + InstanceActions: []InstanceAction{ + { + Type: ActionCreate, + Target: config.InfraTemplate{ + CloudletOrg: "org1", + CloudletName: "cloudlet1", + Region: "US", + }, + InstanceName: "instance1", + Desired: &InstanceState{Name: "instance1"}, + }, + { + Type: ActionUpdate, + Target: config.InfraTemplate{ + CloudletOrg: "org2", + CloudletName: "cloudlet2", + Region: "EU", + }, + InstanceName: "instance2", + Desired: &InstanceState{Name: "instance2"}, + }, + }, + } + + // Test IsEmpty + assert.False(t, plan.IsEmpty()) + + // Test GetTargetCloudlets + cloudlets := plan.GetTargetCloudlets() + assert.Len(t, cloudlets, 2) + assert.Contains(t, cloudlets, "org1:cloudlet1") + assert.Contains(t, cloudlets, "org2:cloudlet2") + + // Test GetTargetRegions + regions := plan.GetTargetRegions() + assert.Len(t, regions, 2) + assert.Contains(t, regions, "US") + assert.Contains(t, regions, "EU") + + // Test GenerateSummary + summary := plan.GenerateSummary() + assert.Contains(t, summary, "test-plan") + assert.Contains(t, summary, "CREATE application") + assert.Contains(t, summary, "CREATE 1 instance") + assert.Contains(t, summary, "UPDATE 1 instance") + + // Test Validate + err := plan.Validate() + assert.NoError(t, err) + + // Test validation failure + plan.AppAction.Desired = nil + err = plan.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "must have desired state") +} + +func TestEstimateDeploymentDuration(t *testing.T) { + planner := &EdgeConnectPlanner{} + + plan := &DeploymentPlan{ + AppAction: AppAction{Type: ActionCreate}, + InstanceActions: []InstanceAction{ + {Type: ActionCreate}, + {Type: ActionUpdate}, + }, + } + + duration := planner.estimateDeploymentDuration(plan) + assert.Greater(t, duration, time.Duration(0)) + assert.Less(t, duration, 10*time.Minute) // Reasonable upper bound + + // Test with no actions + emptyPlan := &DeploymentPlan{ + AppAction: AppAction{Type: ActionNone}, + InstanceActions: []InstanceAction{}, + } + + emptyDuration := planner.estimateDeploymentDuration(emptyPlan) + assert.Greater(t, emptyDuration, time.Duration(0)) + assert.Less(t, emptyDuration, duration) // Should be less than plan with actions +} + +func TestIsResourceNotFoundError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + {"nil error", nil, false}, + {"not found error", &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true}, + {"does not exist error", &edgeconnect.APIError{Messages: []string{"App does not exist"}}, true}, + {"404 in message", &edgeconnect.APIError{Messages: []string{"HTTP 404 error"}}, true}, + {"other error", &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isResourceNotFoundError(tt.err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPlanErrorHandling(t *testing.T) { + mockClient := &MockEdgeConnectClient{} + planner := NewPlanner(mockClient) + testConfig := createTestConfig(t) + + // Mock API call to return a non-404 error + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). + Return(nil, &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) + + ctx := context.Background() + result, err := planner.Plan(ctx, testConfig) + + assert.Error(t, err) + assert.NotNil(t, result) + assert.NotNil(t, result.Error) + assert.Contains(t, err.Error(), "failed to query current app state") + + mockClient.AssertExpectations(t) +} diff --git a/internal/apply/strategy.go b/internal/apply/v1/strategy.go similarity index 99% rename from internal/apply/strategy.go rename to internal/apply/v1/strategy.go index 8d32d2e..44f2471 100644 --- a/internal/apply/strategy.go +++ b/internal/apply/v1/strategy.go @@ -1,6 +1,6 @@ // ABOUTME: Deployment strategy framework for EdgeConnect apply command // ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling) -package apply +package v1 import ( "context" diff --git a/internal/apply/v1/strategy_recreate.go b/internal/apply/v1/strategy_recreate.go new file mode 100644 index 0000000..1f6f121 --- /dev/null +++ b/internal/apply/v1/strategy_recreate.go @@ -0,0 +1,548 @@ +// ABOUTME: Recreate deployment strategy implementation for EdgeConnect +// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution +package v1 + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "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 +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 + } + + 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)) + 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") + + appKey := edgeconnect.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 := edgeconnect.AppInstanceKey{ + Organization: action.Desired.Organization, + Name: action.InstanceName, + CloudletKey: edgeconnect.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 := &edgeconnect.NewAppInstanceInput{ + Region: action.Target.Region, + AppInst: edgeconnect.AppInstance{ + Key: edgeconnect.AppInstanceKey{ + Organization: action.Desired.Organization, + Name: action.InstanceName, + CloudletKey: edgeconnect.CloudletKey{ + Organization: action.Target.CloudletOrg, + Name: action.Target.CloudletName, + }, + }, + AppKey: edgeconnect.AppKey{ + Organization: action.Desired.Organization, + Name: config.Metadata.Name, + Version: config.Metadata.AppVersion, + }, + Flavor: edgeconnect.Flavor{ + Name: action.Target.FlavorName, + }, + }, + } + + // 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 := &edgeconnect.NewAppInput{ + Region: action.Desired.Region, + App: edgeconnect.App{ + Key: edgeconnect.AppKey{ + Organization: action.Desired.Organization, + Name: action.Desired.Name, + Version: action.Desired.Version, + }, + Deployment: config.GetDeploymentType(), + ImageType: "ImageTypeDocker", + ImagePath: config.GetImagePath(), + AllowServerless: true, + DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, + ServerlessConfig: struct{}{}, + DeploymentManifest: manifestContent, + DeploymentGenerator: "kubernetes-basic", + }, + } + + // 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 +} + +// 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 + } + + // Check if it's an APIError with a status code + var apiErr *edgeconnect.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 +} diff --git a/internal/apply/v1/types.go b/internal/apply/v1/types.go new file mode 100644 index 0000000..223fa74 --- /dev/null +++ b/internal/apply/v1/types.go @@ -0,0 +1,462 @@ +// ABOUTME: Deployment planning types for EdgeConnect apply command with state management +// ABOUTME: Defines structures for deployment plans, actions, and state comparison results +package v1 + +import ( + "fmt" + "strings" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" +) + +// SecurityRule defines network access rules (alias to SDK type for consistency) +type SecurityRule = edgeconnect.SecurityRule + +// ActionType represents the type of action to be performed +type ActionType string + +const ( + // ActionCreate indicates a resource needs to be created + ActionCreate ActionType = "CREATE" + // ActionUpdate indicates a resource needs to be updated + ActionUpdate ActionType = "UPDATE" + // ActionNone indicates no action is needed + ActionNone ActionType = "NONE" + // ActionDelete indicates a resource needs to be deleted (for rollback scenarios) + ActionDelete ActionType = "DELETE" +) + +// String returns the string representation of ActionType +func (a ActionType) String() string { + return string(a) +} + +// DeploymentPlan represents the complete deployment plan for a configuration +type DeploymentPlan struct { + // ConfigName is the name from metadata + ConfigName string + + // AppAction defines what needs to be done with the application + AppAction AppAction + + // InstanceActions defines what needs to be done with each instance + InstanceActions []InstanceAction + + // Summary provides a human-readable summary of the plan + Summary string + + // TotalActions is the count of all actions that will be performed + TotalActions int + + // EstimatedDuration is the estimated time to complete the deployment + EstimatedDuration time.Duration + + // CreatedAt timestamp when the plan was created + CreatedAt time.Time + + // DryRun indicates if this is a dry-run plan + DryRun bool +} + +// AppAction represents an action to be performed on an application +type AppAction struct { + // Type of action to perform + Type ActionType + + // Current state of the app (nil if doesn't exist) + Current *AppState + + // Desired state of the app + Desired *AppState + + // Changes describes what will change + Changes []string + + // Reason explains why this action is needed + Reason string + + // ManifestHash is the hash of the current manifest file + ManifestHash string + + // ManifestChanged indicates if the manifest content has changed + ManifestChanged bool +} + +// InstanceAction represents an action to be performed on an application instance +type InstanceAction struct { + // Type of action to perform + Type ActionType + + // Target infrastructure where the instance will be deployed + Target config.InfraTemplate + + // Current state of the instance (nil if doesn't exist) + Current *InstanceState + + // Desired state of the instance + Desired *InstanceState + + // Changes describes what will change + Changes []string + + // Reason explains why this action is needed + Reason string + + // InstanceName is the generated name for this instance + InstanceName string + + // Dependencies lists other instances this depends on + Dependencies []string +} + +// AppState represents the current state of an application +type AppState struct { + // Name of the application + Name string + + // Version of the application + Version string + + // Organization that owns the app + Organization string + + // Region where the app is deployed + Region string + + // ManifestHash is the stored hash of the manifest file + ManifestHash string + + // LastUpdated timestamp when the app was last modified + LastUpdated time.Time + + // Exists indicates if the app currently exists + Exists bool + + // AppType indicates whether this is a k8s or docker app + AppType AppType + + // OutboundConnections contains the required outbound network connections + OutboundConnections []SecurityRule +} + +// InstanceState represents the current state of an application instance +type InstanceState struct { + // Name of the instance + Name string + + // AppName that this instance belongs to + AppName string + + // AppVersion of the associated app + AppVersion string + + // Organization that owns the instance + Organization string + + // Region where the instance is deployed + Region string + + // CloudletOrg that hosts the cloudlet + CloudletOrg string + + // CloudletName where the instance is running + CloudletName string + + // FlavorName used for the instance + FlavorName string + + // State of the instance (e.g., "Ready", "Pending", "Error") + State string + + // PowerState of the instance + PowerState string + + // LastUpdated timestamp when the instance was last modified + LastUpdated time.Time + + // Exists indicates if the instance currently exists + Exists bool +} + +// AppType represents the type of application +type AppType string + +const ( + // AppTypeK8s represents a Kubernetes application + AppTypeK8s AppType = "k8s" + // AppTypeDocker represents a Docker application + AppTypeDocker AppType = "docker" +) + +// String returns the string representation of AppType +func (a AppType) String() string { + return string(a) +} + +// DeploymentSummary provides a high-level overview of the deployment plan +type DeploymentSummary struct { + // TotalActions is the total number of actions to be performed + TotalActions int + + // ActionCounts breaks down actions by type + ActionCounts map[ActionType]int + + // EstimatedDuration for the entire deployment + EstimatedDuration time.Duration + + // ResourceSummary describes the resources involved + ResourceSummary ResourceSummary + + // Warnings about potential issues + Warnings []string +} + +// ResourceSummary provides details about resources in the deployment +type ResourceSummary struct { + // AppsToCreate number of apps that will be created + AppsToCreate int + + // AppsToUpdate number of apps that will be updated + AppsToUpdate int + + // InstancesToCreate number of instances that will be created + InstancesToCreate int + + // InstancesToUpdate number of instances that will be updated + InstancesToUpdate int + + // CloudletsAffected number of unique cloudlets involved + CloudletsAffected int + + // RegionsAffected number of unique regions involved + RegionsAffected int +} + +// PlanResult represents the result of a deployment planning operation +type PlanResult struct { + // Plan is the generated deployment plan + Plan *DeploymentPlan + + // Error if planning failed + Error error + + // Warnings encountered during planning + Warnings []string +} + +// ExecutionResult represents the result of executing a deployment plan +type ExecutionResult struct { + // Plan that was executed + Plan *DeploymentPlan + + // Success indicates if the deployment was successful + Success bool + + // CompletedActions lists actions that were successfully completed + CompletedActions []ActionResult + + // FailedActions lists actions that failed + FailedActions []ActionResult + + // Error that caused the deployment to fail (if any) + Error error + + // Duration taken to execute the plan + Duration time.Duration + + // RollbackPerformed indicates if rollback was executed + RollbackPerformed bool + + // RollbackSuccess indicates if rollback was successful + RollbackSuccess bool +} + +// ActionResult represents the result of executing a single action +type ActionResult struct { + // Type of action that was attempted + Type ActionType + + // Target describes what was being acted upon + Target string + + // Success indicates if the action succeeded + Success bool + + // Error if the action failed + Error error + + // Duration taken to complete the action + Duration time.Duration + + // Details provides additional information about the action + Details string +} + +// IsEmpty returns true if the deployment plan has no actions to perform +func (dp *DeploymentPlan) IsEmpty() bool { + if dp.AppAction.Type != ActionNone { + return false + } + + for _, action := range dp.InstanceActions { + if action.Type != ActionNone { + return false + } + } + + return true +} + +// HasErrors returns true if the plan contains any error conditions +func (dp *DeploymentPlan) HasErrors() bool { + // Check for conflicting actions or invalid states + return false // Implementation would check for various error conditions +} + +// GetTargetCloudlets returns a list of unique cloudlets that will be affected +func (dp *DeploymentPlan) GetTargetCloudlets() []string { + cloudletSet := make(map[string]bool) + var cloudlets []string + + for _, action := range dp.InstanceActions { + if action.Type != ActionNone { + key := fmt.Sprintf("%s:%s", action.Target.CloudletOrg, action.Target.CloudletName) + if !cloudletSet[key] { + cloudletSet[key] = true + cloudlets = append(cloudlets, key) + } + } + } + + return cloudlets +} + +// GetTargetRegions returns a list of unique regions that will be affected +func (dp *DeploymentPlan) GetTargetRegions() []string { + regionSet := make(map[string]bool) + var regions []string + + for _, action := range dp.InstanceActions { + if action.Type != ActionNone && !regionSet[action.Target.Region] { + regionSet[action.Target.Region] = true + regions = append(regions, action.Target.Region) + } + } + + return regions +} + +// GenerateSummary creates a human-readable summary of the deployment plan +func (dp *DeploymentPlan) GenerateSummary() string { + if dp.IsEmpty() { + return "No changes required - configuration matches current state" + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName)) + + // App actions + if dp.AppAction.Type != ActionNone { + sb.WriteString(fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name)) + if len(dp.AppAction.Changes) > 0 { + for _, change := range dp.AppAction.Changes { + sb.WriteString(fmt.Sprintf(" - %s\n", change)) + } + } + } + + // Instance actions + createCount := 0 + updateActions := []InstanceAction{} + for _, action := range dp.InstanceActions { + switch action.Type { + case ActionCreate: + createCount++ + case ActionUpdate: + updateActions = append(updateActions, action) + } + } + + if createCount > 0 { + sb.WriteString(fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets()))) + } + + if len(updateActions) > 0 { + sb.WriteString(fmt.Sprintf("- UPDATE %d instance(s)\n", len(updateActions))) + for _, action := range updateActions { + if len(action.Changes) > 0 { + sb.WriteString(fmt.Sprintf(" - Instance '%s':\n", action.InstanceName)) + for _, change := range action.Changes { + sb.WriteString(fmt.Sprintf(" - %s\n", change)) + } + } + } + } + + sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String())) + + return sb.String() +} + +// Validate checks if the deployment plan is valid and safe to execute +func (dp *DeploymentPlan) Validate() error { + if dp.ConfigName == "" { + return fmt.Errorf("deployment plan must have a config name") + } + + // Validate app action + if dp.AppAction.Type != ActionNone && dp.AppAction.Desired == nil { + return fmt.Errorf("app action of type %s must have desired state", dp.AppAction.Type) + } + + // Validate instance actions + for i, action := range dp.InstanceActions { + if action.Type != ActionNone { + if action.Desired == nil { + return fmt.Errorf("instance action %d of type %s must have desired state", i, action.Type) + } + if action.InstanceName == "" { + return fmt.Errorf("instance action %d must have an instance name", i) + } + } + } + + return nil +} + +// Clone creates a deep copy of the deployment plan +func (dp *DeploymentPlan) Clone() *DeploymentPlan { + clone := &DeploymentPlan{ + ConfigName: dp.ConfigName, + Summary: dp.Summary, + TotalActions: dp.TotalActions, + EstimatedDuration: dp.EstimatedDuration, + CreatedAt: dp.CreatedAt, + DryRun: dp.DryRun, + AppAction: dp.AppAction, // Struct copy is sufficient for this use case + } + + // Deep copy instance actions + clone.InstanceActions = make([]InstanceAction, len(dp.InstanceActions)) + copy(clone.InstanceActions, dp.InstanceActions) + + return clone +} + +// convertNetworkRules converts config network rules to EdgeConnect SecurityRules +func convertNetworkRules(network *config.NetworkConfig) []edgeconnect.SecurityRule { + rules := make([]edgeconnect.SecurityRule, len(network.OutboundConnections)) + + for i, conn := range network.OutboundConnections { + rules[i] = edgeconnect.SecurityRule{ + Protocol: conn.Protocol, + PortRangeMin: conn.PortRangeMin, + PortRangeMax: conn.PortRangeMax, + RemoteCIDR: conn.RemoteCIDR, + } + } + + return rules +} diff --git a/internal/apply/manager.go b/internal/apply/v2/manager.go similarity index 99% rename from internal/apply/manager.go rename to internal/apply/v2/manager.go index 3e6d837..fc1b483 100644 --- a/internal/apply/manager.go +++ b/internal/apply/v2/manager.go @@ -1,6 +1,6 @@ // 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 apply +package v2 import ( "context" diff --git a/internal/apply/manager_test.go b/internal/apply/v2/manager_test.go similarity index 99% rename from internal/apply/manager_test.go rename to internal/apply/v2/manager_test.go index f2135b5..68c60fd 100644 --- a/internal/apply/manager_test.go +++ b/internal/apply/v2/manager_test.go @@ -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 apply +package v2 import ( "context" diff --git a/internal/apply/planner.go b/internal/apply/v2/planner.go similarity index 99% rename from internal/apply/planner.go rename to internal/apply/v2/planner.go index d4f3e82..52de1ee 100644 --- a/internal/apply/planner.go +++ b/internal/apply/v2/planner.go @@ -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 apply +package v2 import ( "context" diff --git a/internal/apply/planner_test.go b/internal/apply/v2/planner_test.go similarity index 99% rename from internal/apply/planner_test.go rename to internal/apply/v2/planner_test.go index 6f7c39b..fe56871 100644 --- a/internal/apply/planner_test.go +++ b/internal/apply/v2/planner_test.go @@ -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 apply +package v2 import ( "context" diff --git a/internal/apply/v2/strategy.go b/internal/apply/v2/strategy.go new file mode 100644 index 0000000..6a1661a --- /dev/null +++ b/internal/apply/v2/strategy.go @@ -0,0 +1,106 @@ +// 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/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 + } +} diff --git a/internal/apply/strategy_recreate.go b/internal/apply/v2/strategy_recreate.go similarity index 99% rename from internal/apply/strategy_recreate.go rename to internal/apply/v2/strategy_recreate.go index dc44784..739a454 100644 --- a/internal/apply/strategy_recreate.go +++ b/internal/apply/v2/strategy_recreate.go @@ -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 apply +package v2 import ( "context" diff --git a/internal/apply/types.go b/internal/apply/v2/types.go similarity index 99% rename from internal/apply/types.go rename to internal/apply/v2/types.go index 279832a..90b7956 100644 --- a/internal/apply/types.go +++ b/internal/apply/v2/types.go @@ -1,6 +1,6 @@ // ABOUTME: Deployment planning types for EdgeConnect apply command with state management // ABOUTME: Defines structures for deployment plans, actions, and state comparison results -package apply +package v2 import ( "fmt" From f921169351417e5003d0a51a4695ca0b48a4a4d3 Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Mon, 20 Oct 2025 14:29:45 +0200 Subject: [PATCH 06/21] feat(examples): added edge connect v1 and v2 examples --- .../comprehensive/EdgeConnectConfig_v1.yaml | 29 +++++++++++++++++++ ...tConfig.yaml => EdgeConnectConfig_v2.yaml} | 0 2 files changed, 29 insertions(+) create mode 100644 sdk/examples/comprehensive/EdgeConnectConfig_v1.yaml rename sdk/examples/comprehensive/{EdgeConnectConfig.yaml => EdgeConnectConfig_v2.yaml} (100%) diff --git a/sdk/examples/comprehensive/EdgeConnectConfig_v1.yaml b/sdk/examples/comprehensive/EdgeConnectConfig_v1.yaml new file mode 100644 index 0000000..b45abc4 --- /dev/null +++ b/sdk/examples/comprehensive/EdgeConnectConfig_v1.yaml @@ -0,0 +1,29 @@ +# 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.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" diff --git a/sdk/examples/comprehensive/EdgeConnectConfig.yaml b/sdk/examples/comprehensive/EdgeConnectConfig_v2.yaml similarity index 100% rename from sdk/examples/comprehensive/EdgeConnectConfig.yaml rename to sdk/examples/comprehensive/EdgeConnectConfig_v2.yaml From df697c0ff6ad6c888050c246e4d1d54a7631783a Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Mon, 20 Oct 2025 15:15:23 +0200 Subject: [PATCH 07/21] fix(sdk): correct delete payload structure for v2 API and add delete command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 API requires a different JSON payload structure than what was being sent. Both DeleteApp and DeleteAppInstance needed to wrap their parameters properly. SDK Changes: - Update DeleteAppInput to use {region, app: {key}} structure - Update DeleteAppInstanceInput to use {region, appinst: {key}} structure - Fix DeleteApp method to populate new payload structure - Fix DeleteAppInstance method to populate new payload structure CLI Changes: - Add delete command with -f flag for config file specification - Support --dry-run to preview deletions - Support --auto-approve to skip confirmation - Implement v1 and v2 API support following same pattern as apply - Add deletion planner to discover resources matching config - Add resource manager to execute deletions (instances first, then app) Test Changes: - Update example_test.go to use EdgeConnectConfig_v1.yaml - All tests passing including comprehensive delete test coverage Verified working with manual API testing against live endpoint. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/delete.go | 294 +++++++++++++++++++++++++++++ internal/config/example_test.go | 2 +- internal/delete/v1/manager.go | 166 ++++++++++++++++ internal/delete/v1/planner.go | 228 ++++++++++++++++++++++ internal/delete/v1/types.go | 157 +++++++++++++++ internal/delete/v2/manager.go | 166 ++++++++++++++++ internal/delete/v2/manager_test.go | 200 ++++++++++++++++++++ internal/delete/v2/planner.go | 228 ++++++++++++++++++++++ internal/delete/v2/planner_test.go | 219 +++++++++++++++++++++ internal/delete/v2/types.go | 157 +++++++++++++++ internal/delete/v2/types_test.go | 95 ++++++++++ sdk/edgeconnect/v2/appinstance.go | 3 +- sdk/edgeconnect/v2/apps.go | 2 +- sdk/edgeconnect/v2/types.go | 9 +- 14 files changed, 1921 insertions(+), 5 deletions(-) create mode 100644 cmd/delete.go create mode 100644 internal/delete/v1/manager.go create mode 100644 internal/delete/v1/planner.go create mode 100644 internal/delete/v1/types.go create mode 100644 internal/delete/v2/manager.go create mode 100644 internal/delete/v2/manager_test.go create mode 100644 internal/delete/v2/planner.go create mode 100644 internal/delete/v2/planner_test.go create mode 100644 internal/delete/v2/types.go create mode 100644 internal/delete/v2/types_test.go diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..912741b --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,294 @@ +// 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" + + deletev1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/delete/v1" + deletev2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/delete/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "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") + + deleteCmd.MarkFlagRequired("file") +} diff --git a/internal/config/example_test.go b/internal/config/example_test.go index dfa3840..536399f 100644 --- a/internal/config/example_test.go +++ b/internal/config/example_test.go @@ -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.yaml") + examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig_v1.yaml") config, parsedManifest, err := parser.ParseFile(examplePath) // This should now succeed with full validation diff --git a/internal/delete/v1/manager.go b/internal/delete/v1/manager.go new file mode 100644 index 0000000..470ac37 --- /dev/null +++ b/internal/delete/v1/manager.go @@ -0,0 +1,166 @@ +// 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/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...) + } +} diff --git a/internal/delete/v1/planner.go b/internal/delete/v1/planner.go new file mode 100644 index 0000000..d436057 --- /dev/null +++ b/internal/delete/v1/planner.go @@ -0,0 +1,228 @@ +// 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/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/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, 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, + }, + } + + instances, err := p.client.ShowAppInstances(ctx, instanceKey, 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 +} diff --git a/internal/delete/v1/types.go b/internal/delete/v1/types.go new file mode 100644 index 0000000..a4d491c --- /dev/null +++ b/internal/delete/v1/types.go @@ -0,0 +1,157 @@ +// 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 +} diff --git a/internal/delete/v2/manager.go b/internal/delete/v2/manager.go new file mode 100644 index 0000000..a644f32 --- /dev/null +++ b/internal/delete/v2/manager.go @@ -0,0 +1,166 @@ +// 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/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...) + } +} diff --git a/internal/delete/v2/manager_test.go b/internal/delete/v2/manager_test.go new file mode 100644 index 0000000..fd098af --- /dev/null +++ b/internal/delete/v2/manager_test.go @@ -0,0 +1,200 @@ +// 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/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, 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) +} diff --git a/internal/delete/v2/planner.go b/internal/delete/v2/planner.go new file mode 100644 index 0000000..e77cd9e --- /dev/null +++ b/internal/delete/v2/planner.go @@ -0,0 +1,228 @@ +// 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/internal/config" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/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, 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, + }, + } + + instances, err := p.client.ShowAppInstances(ctx, instanceKey, 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 +} diff --git a/internal/delete/v2/planner_test.go b/internal/delete/v2/planner_test.go new file mode 100644 index 0000000..c37a318 --- /dev/null +++ b/internal/delete/v2/planner_test.go @@ -0,0 +1,219 @@ +// 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/internal/config" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/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, 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) +} diff --git a/internal/delete/v2/types.go b/internal/delete/v2/types.go new file mode 100644 index 0000000..de50a68 --- /dev/null +++ b/internal/delete/v2/types.go @@ -0,0 +1,157 @@ +// 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 +} diff --git a/internal/delete/v2/types_test.go b/internal/delete/v2/types_test.go new file mode 100644 index 0000000..8dfa6b0 --- /dev/null +++ b/internal/delete/v2/types_test.go @@ -0,0 +1,95 @@ +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)") +} diff --git a/sdk/edgeconnect/v2/appinstance.go b/sdk/edgeconnect/v2/appinstance.go index 57e6b3c..4fb7204 100644 --- a/sdk/edgeconnect/v2/appinstance.go +++ b/sdk/edgeconnect/v2/appinstance.go @@ -173,8 +173,9 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst" input := DeleteAppInstanceInput{ - Key: appInstKey, + Region: region, } + input.AppInst.Key = appInstKey resp, err := transport.Call(ctx, "POST", url, input) if err != nil { diff --git a/sdk/edgeconnect/v2/apps.go b/sdk/edgeconnect/v2/apps.go index ce5bb76..06d529f 100644 --- a/sdk/edgeconnect/v2/apps.go +++ b/sdk/edgeconnect/v2/apps.go @@ -144,9 +144,9 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp" input := DeleteAppInput{ - Key: appKey, Region: region, } + input.App.Key = appKey resp, err := transport.Call(ctx, "POST", url, input) if err != nil { diff --git a/sdk/edgeconnect/v2/types.go b/sdk/edgeconnect/v2/types.go index 82995e0..0bb6875 100644 --- a/sdk/edgeconnect/v2/types.go +++ b/sdk/edgeconnect/v2/types.go @@ -273,13 +273,18 @@ type UpdateAppInstanceInput struct { // DeleteAppInput represents input for deleting an application type DeleteAppInput struct { - Key AppKey `json:"key"` Region string `json:"region"` + App struct { + Key AppKey `json:"key"` + } `json:"app"` } // DeleteAppInstanceInput represents input for deleting an app instance type DeleteAppInstanceInput struct { - Key AppInstanceKey `json:"key"` + Region string `json:"region"` + AppInst struct { + Key AppInstanceKey `json:"key"` + } `json:"appinst"` } // Response wrapper types From a70e107a3fef8c58052ab421d176f1b5fc9519db Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Mon, 20 Oct 2025 15:55:58 +0200 Subject: [PATCH 08/21] feat(signing): added goreleaser signing --- .github/workflows/release.yaml | 7 +++++++ .goreleaser.yaml | 21 ++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d2a754b..3040258 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,9 +19,16 @@ 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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e92295f..9d098eb 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -10,11 +10,11 @@ builds: - CGO_ENABLED=0 goos: - linux - - darwin - - windows + #- darwin + #- windows goarch: - amd64 - - arm64 + #- arm64 archives: - formats: [tar.gz] @@ -31,6 +31,21 @@ archives: - goos: windows formats: [zip] +signs: + - artifacts: checksum + cmd: gpg2 + args: + - "--batch" + - "-u" + - "{{ .Env.GPG_FINGERPRINT }}" + - "--output" + - "${signature}" + - "--detach-sign" + - "${artifact}" + +#binary_signs: +# - {} + changelog: abbrev: 10 filters: From 318af7baff86a3c3f8214ca47c0cea217c35ec52 Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Mon, 20 Oct 2025 15:59:05 +0200 Subject: [PATCH 09/21] feat(signing): added goreleaser signing --- .goreleaser.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9d098eb..4731016 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -33,7 +33,7 @@ archives: signs: - artifacts: checksum - cmd: gpg2 + cmd: gpg args: - "--batch" - "-u" From 65e018506475d71f8808c739ac126cb639122341 Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Mon, 20 Oct 2025 16:47:00 +0200 Subject: [PATCH 10/21] feat(signing): added public key --- public.gpg | Bin 0 -> 2298 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public.gpg diff --git a/public.gpg b/public.gpg new file mode 100644 index 0000000000000000000000000000000000000000..32d15f1381b15652b515672192b0264a83eccbea GIT binary patch literal 2298 zcmajf`9Bj51HkcZj?7$5?mI&6FF8W)p|HsnBi9@ob7UKHMOvsizOIQxwJ3?oy%KUv zjUsaw5<+t1E9aW;bG)9{^ZfSw0iWMKuXhQEpA)@vt`H~$XgoQp{V}`l6eGfWvYl&{ zGjDMvvd^_0bdUAI$Et!AQ=O7Jood4~o)J+Xtfnxu4snk(9^sUZUEY<615RDe-t$?% z!;o7&e!AX}x&8cO@SINDAZ|lS5KYQMkU{`-qh?;nc@lu^Q4mLyz9 z8mcY8PW1Fu<6hL%yWwO8z8D1Q#H-BFuKU<*CqNvhvg@7c{A}f7Fei=(FG@g@|WgBlwFO9cCr}Zll4#rX>FG(z4r+3YJ-P`TRDqMH2UWPjaAO%}dSl9rbj2 zh68sF61Ta4KLCYYKw7kIX8$_VXZIpwgU5w=jyl~w5F$HE`Xc?HuQ@8uj&iZg`c>2P z7V_d3v0a$r=TyV^3c~eX@Dj1`Q!r(N6f@4GCEFvOsnm9sqbW{vpD=66PCdiFb7|07S z6II}JwTD;6CJ5_4qIfLr^nyG#l~o8_pno0*lH%Y3b5ToExgcPkV<2u3UN8>?#3>5m z0D@$JAhA>cFIeb5N##>PwxS~fnUSymIf+URQX5`6vP(*d)nBS3E_IfLqU}poM$45e zHS4`Fu5DI~7$xjSt1fd!q6M5O87#ps5Odw9`ii@%PUj)9J$;mAa{!!{G@|&#%7)pT z#eJRNyE#!oQ!L@7VPM1enR9`@9GwuUr_J&FhC^T&l7ZxTK&IO)nYYAl| z-Axv;h8_=}3E`)v&Y;aV<^-Q?=@$0Qmb{J~3Y1NCqqie_CwDF%{#9EbA}%_A3({6uJE2yj{I|8uBM)^~!k9L!6rG@3zjyd}f#>)5ldfST*Vi{1sld zg*{#hVYL5J^@c>LJM2n+JTy3YV=8X8%r$i!m^AllEK|-0wZn8)vu+ftw1}FQJ-_$N zz^SLnogE%vuAXb52;n0x=V*?AcBp&kx%rH`OWXSSOCae|VzI-iZW^JpW&WfbcH3c$ zgF22HRObE|mbT!x$cPd&VrGs@N2f%QunnhSr6I~^m0tMi^7$+OmWe_jNN&6WgPH&( zJMFm2{Exjn?P5&F229W-&a~EZ#UL>&TE#IjMar-t?;B;r_v$LIuHvKZrjst|FMVdD zWfJ%Ff4grFbeMFlJbeOdoY{|@jF6naUSvvkQZ2nFcbFQHSk)?*$pH*^8dxU;Orm66` ze(i1$Z)4XFR%fQy&4p_Z>~mNk9nr}~;uRNQy~8mxPX?KSh%Td*xRRld8|AJU4kk98 zat_~$z_N>Ie^#nYJA)>p!D~+t5CJUX?%8)WKf2=S-0Sqr8g#pOPPls0k^rh*Zlc)Itw0_?!GBS8 z6J*LM{tsr9{|_^K|7PZYIVpWBcw{j#BSdacN1KS{(tR>5??$$$ONcC}i-%cKsrYm6 z?t9+o!*u-GaVeDe6O&9WSgR;csEK==-3m$6ZNB`_u$>wyA3X4LE%E>WAi*V{fc4ycTj#}$57*%x@B?>)D)p_>w(5oBmR%1` z_GYvvDSGNmyeW6V84Kj+#k7J{TjH{CCW~5zS1VZFK_4o98 z$)xd|NLdS1d{6sJA_bM%LbH7JxaMZ7%F&dLS*WGA2;d4r38&s+bZ7x=Rl2VHg2p$r zMlv~OqWT$KOsOfBnv`1C`e~46kmyKUA4f7=-GJ+epxqETYzuGXI5AVQ(B8#>gHWj5 zoI-SAHmw~MmpgL!Zmtl9CzoukpN043w)`d&Ps;hE^WH+vuaF;n7nYDNdH9EF(7n!^ zPgFdd*EGlp4_XW)1iX)qWCj*u>`73B22Q-6IIvK8UN&_b=p2u_z(qh_9Vw ztf#skLCh>$1Q1!5G73Etba?!(6KVKF QHln&}=F?@5RMy$Q0gR196#xJL literal 0 HcmV?d00001 From 9cb9f97a1f1ed5e0f5d47082b4d01c2170e76915 Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Mon, 20 Oct 2025 16:49:41 +0200 Subject: [PATCH 11/21] feat(signing): added multi arch build --- .goreleaser.yaml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 4731016..248c94f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -10,11 +10,11 @@ builds: - CGO_ENABLED=0 goos: - linux - #- darwin - #- windows + - darwin + - windows goarch: - amd64 - #- arm64 + - arm64 archives: - formats: [tar.gz] @@ -43,9 +43,6 @@ signs: - "--detach-sign" - "${artifact}" -#binary_signs: -# - {} - changelog: abbrev: 10 filters: From 716c8e79e415f641ed123e89f61db1dffb184611 Mon Sep 17 00:00:00 2001 From: Martin McCaffery Date: Tue, 21 Oct 2025 11:40:35 +0200 Subject: [PATCH 12/21] fix(version): update imports and go.mod to allow v2 --- cmd/app.go | 4 ++-- cmd/apply.go | 6 +++--- cmd/delete.go | 6 +++--- cmd/instance.go | 4 ++-- go.mod | 2 +- internal/apply/v1/manager.go | 4 ++-- internal/apply/v1/manager_test.go | 4 ++-- internal/apply/v1/planner.go | 12 ++++++------ internal/apply/v1/planner_test.go | 4 ++-- internal/apply/v1/strategy.go | 2 +- internal/apply/v1/strategy_recreate.go | 4 ++-- internal/apply/v1/types.go | 4 ++-- internal/apply/v2/manager.go | 4 ++-- internal/apply/v2/manager_test.go | 4 ++-- internal/apply/v2/planner.go | 12 ++++++------ internal/apply/v2/planner_test.go | 4 ++-- internal/apply/v2/strategy.go | 2 +- internal/apply/v2/strategy_recreate.go | 4 ++-- internal/apply/v2/types.go | 4 ++-- internal/delete/v1/manager.go | 2 +- internal/delete/v1/planner.go | 4 ++-- internal/delete/v2/manager.go | 2 +- internal/delete/v2/manager_test.go | 2 +- internal/delete/v2/planner.go | 4 ++-- internal/delete/v2/planner_test.go | 4 ++-- main.go | 2 +- sdk/README.md | 2 +- sdk/edgeconnect/appinstance.go | 2 +- sdk/edgeconnect/apps.go | 2 +- sdk/edgeconnect/cloudlet.go | 2 +- sdk/edgeconnect/v2/appinstance.go | 2 +- sdk/edgeconnect/v2/apps.go | 2 +- sdk/edgeconnect/v2/cloudlet.go | 2 +- sdk/examples/comprehensive/main.go | 2 +- sdk/examples/deploy_app.go | 2 +- 35 files changed, 64 insertions(+), 64 deletions(-) diff --git a/cmd/app.go b/cmd/app.go index 79fc2c5..02125fc 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -10,8 +10,8 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" "github.com/spf13/cobra" "github.com/spf13/viper" ) diff --git a/cmd/apply.go b/cmd/apply.go index 1493841..e2affd0 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -10,9 +10,9 @@ import ( "path/filepath" "strings" - applyv1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply/v1" - applyv2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply/v2" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + 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" "github.com/spf13/cobra" ) diff --git a/cmd/delete.go b/cmd/delete.go index 912741b..7124e61 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -10,9 +10,9 @@ import ( "path/filepath" "strings" - deletev1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/delete/v1" - deletev2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/delete/v2" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "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" ) diff --git a/cmd/instance.go b/cmd/instance.go index 1eb6cb6..0b78986 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -5,8 +5,8 @@ import ( "fmt" "os" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" "github.com/spf13/cobra" ) diff --git a/go.mod b/go.mod index dd77621..e88a974 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module edp.buildth.ing/DevFW-CICD/edge-connect-client +module edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 go 1.25.1 diff --git a/internal/apply/v1/manager.go b/internal/apply/v1/manager.go index a0668e8..048e85e 100644 --- a/internal/apply/v1/manager.go +++ b/internal/apply/v1/manager.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" ) // ResourceManagerInterface defines the interface for resource management diff --git a/internal/apply/v1/manager_test.go b/internal/apply/v1/manager_test.go index 9ed3cac..d4b4744 100644 --- a/internal/apply/v1/manager_test.go +++ b/internal/apply/v1/manager_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/internal/apply/v1/planner.go b/internal/apply/v1/planner.go index 33b8d9c..001076c 100644 --- a/internal/apply/v1/planner.go +++ b/internal/apply/v1/planner.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" ) // EdgeConnectClientInterface defines the methods needed for deployment planning @@ -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() { @@ -392,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) diff --git a/internal/apply/v1/planner_test.go b/internal/apply/v1/planner_test.go index 8c1e48a..7761365 100644 --- a/internal/apply/v1/planner_test.go +++ b/internal/apply/v1/planner_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/internal/apply/v1/strategy.go b/internal/apply/v1/strategy.go index 44f2471..db2f90f 100644 --- a/internal/apply/v1/strategy.go +++ b/internal/apply/v1/strategy.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" ) // DeploymentStrategy represents the type of deployment strategy diff --git a/internal/apply/v1/strategy_recreate.go b/internal/apply/v1/strategy_recreate.go index 1f6f121..b8cc736 100644 --- a/internal/apply/v1/strategy_recreate.go +++ b/internal/apply/v1/strategy_recreate.go @@ -10,8 +10,8 @@ import ( "sync" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" ) // RecreateStrategy implements the recreate deployment strategy diff --git a/internal/apply/v1/types.go b/internal/apply/v1/types.go index 223fa74..4863716 100644 --- a/internal/apply/v1/types.go +++ b/internal/apply/v1/types.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" ) // SecurityRule defines network access rules (alias to SDK type for consistency) diff --git a/internal/apply/v2/manager.go b/internal/apply/v2/manager.go index fc1b483..4866129 100644 --- a/internal/apply/v2/manager.go +++ b/internal/apply/v2/manager.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "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 diff --git a/internal/apply/v2/manager_test.go b/internal/apply/v2/manager_test.go index 68c60fd..dd2fc55 100644 --- a/internal/apply/v2/manager_test.go +++ b/internal/apply/v2/manager_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "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" diff --git a/internal/apply/v2/planner.go b/internal/apply/v2/planner.go index 52de1ee..52a5e18 100644 --- a/internal/apply/v2/planner.go +++ b/internal/apply/v2/planner.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "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 @@ -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() { @@ -392,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) diff --git a/internal/apply/v2/planner_test.go b/internal/apply/v2/planner_test.go index fe56871..20d3dab 100644 --- a/internal/apply/v2/planner_test.go +++ b/internal/apply/v2/planner_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "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" diff --git a/internal/apply/v2/strategy.go b/internal/apply/v2/strategy.go index 6a1661a..78e3df4 100644 --- a/internal/apply/v2/strategy.go +++ b/internal/apply/v2/strategy.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" ) // DeploymentStrategy represents the type of deployment strategy diff --git a/internal/apply/v2/strategy_recreate.go b/internal/apply/v2/strategy_recreate.go index 739a454..89c9c56 100644 --- a/internal/apply/v2/strategy_recreate.go +++ b/internal/apply/v2/strategy_recreate.go @@ -10,8 +10,8 @@ import ( "sync" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "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 diff --git a/internal/apply/v2/types.go b/internal/apply/v2/types.go index 90b7956..ae52420 100644 --- a/internal/apply/v2/types.go +++ b/internal/apply/v2/types.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "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) diff --git a/internal/delete/v1/manager.go b/internal/delete/v1/manager.go index 470ac37..e20eba9 100644 --- a/internal/delete/v1/manager.go +++ b/internal/delete/v1/manager.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" ) // ResourceManagerInterface defines the interface for resource management diff --git a/internal/delete/v1/planner.go b/internal/delete/v1/planner.go index d436057..10f41c5 100644 --- a/internal/delete/v1/planner.go +++ b/internal/delete/v1/planner.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" ) // EdgeConnectClientInterface defines the methods needed for deletion planning diff --git a/internal/delete/v2/manager.go b/internal/delete/v2/manager.go index a644f32..35518a2 100644 --- a/internal/delete/v2/manager.go +++ b/internal/delete/v2/manager.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ) // ResourceManagerInterface defines the interface for resource management diff --git a/internal/delete/v2/manager_test.go b/internal/delete/v2/manager_test.go index fd098af..fa2b7c9 100644 --- a/internal/delete/v2/manager_test.go +++ b/internal/delete/v2/manager_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + 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" diff --git a/internal/delete/v2/planner.go b/internal/delete/v2/planner.go index e77cd9e..752fe3b 100644 --- a/internal/delete/v2/planner.go +++ b/internal/delete/v2/planner.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "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 diff --git a/internal/delete/v2/planner_test.go b/internal/delete/v2/planner_test.go index c37a318..2ec9eae 100644 --- a/internal/delete/v2/planner_test.go +++ b/internal/delete/v2/planner_test.go @@ -8,8 +8,8 @@ import ( "path/filepath" "testing" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "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" diff --git a/main.go b/main.go index 9bc902d..2d198e9 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,6 @@ package main -import "edp.buildth.ing/DevFW-CICD/edge-connect-client/cmd" +import "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/cmd" func main() { cmd.Execute() diff --git a/sdk/README.md b/sdk/README.md index 89dc673..be2374f 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -16,7 +16,7 @@ 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/sdk/edgeconnect/v2" +import v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ``` ### Authentication diff --git a/sdk/edgeconnect/appinstance.go b/sdk/edgeconnect/appinstance.go index a26f45c..f655c98 100644 --- a/sdk/edgeconnect/appinstance.go +++ b/sdk/edgeconnect/appinstance.go @@ -9,7 +9,7 @@ import ( "fmt" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http" ) // CreateAppInstance creates a new application instance in the specified region diff --git a/sdk/edgeconnect/apps.go b/sdk/edgeconnect/apps.go index 70f5dea..8973862 100644 --- a/sdk/edgeconnect/apps.go +++ b/sdk/edgeconnect/apps.go @@ -10,7 +10,7 @@ import ( "io" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http" ) var ( diff --git a/sdk/edgeconnect/cloudlet.go b/sdk/edgeconnect/cloudlet.go index e3f4b7d..0ed6e71 100644 --- a/sdk/edgeconnect/cloudlet.go +++ b/sdk/edgeconnect/cloudlet.go @@ -9,7 +9,7 @@ import ( "fmt" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http" ) // CreateCloudlet creates a new cloudlet in the specified region diff --git a/sdk/edgeconnect/v2/appinstance.go b/sdk/edgeconnect/v2/appinstance.go index 4fb7204..d38821e 100644 --- a/sdk/edgeconnect/v2/appinstance.go +++ b/sdk/edgeconnect/v2/appinstance.go @@ -11,7 +11,7 @@ import ( "io" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http" ) // CreateAppInstance creates a new application instance in the specified region diff --git a/sdk/edgeconnect/v2/apps.go b/sdk/edgeconnect/v2/apps.go index 06d529f..8f5410e 100644 --- a/sdk/edgeconnect/v2/apps.go +++ b/sdk/edgeconnect/v2/apps.go @@ -11,7 +11,7 @@ import ( "io" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http" ) var ( diff --git a/sdk/edgeconnect/v2/cloudlet.go b/sdk/edgeconnect/v2/cloudlet.go index 85ef522..415584a 100644 --- a/sdk/edgeconnect/v2/cloudlet.go +++ b/sdk/edgeconnect/v2/cloudlet.go @@ -9,7 +9,7 @@ import ( "fmt" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http" ) // CreateCloudlet creates a new cloudlet in the specified region diff --git a/sdk/examples/comprehensive/main.go b/sdk/examples/comprehensive/main.go index d3fb922..f932a75 100644 --- a/sdk/examples/comprehensive/main.go +++ b/sdk/examples/comprehensive/main.go @@ -12,7 +12,7 @@ import ( "strings" "time" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ) func main() { diff --git a/sdk/examples/deploy_app.go b/sdk/examples/deploy_app.go index 84297dc..d35ff9c 100644 --- a/sdk/examples/deploy_app.go +++ b/sdk/examples/deploy_app.go @@ -12,7 +12,7 @@ import ( "strings" "time" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ) func main() { From 26ba07200e190e15b15ae438721ba8bb2b608de4 Mon Sep 17 00:00:00 2001 From: Stephan Lo Date: Tue, 21 Oct 2025 13:44:33 +0200 Subject: [PATCH 13/21] test(orca-forgjo-runner): added v2 example to deploy forgejo runner in orca --- ...tConfig.yaml => EdgeConnectConfig_v1.yaml} | 0 .../forgejo-runner/EdgeConnectConfig_v2.yaml | 29 +++++++++++++++++++ 2 files changed, 29 insertions(+) rename sdk/examples/forgejo-runner/{EdgeConnectConfig.yaml => EdgeConnectConfig_v1.yaml} (100%) create mode 100644 sdk/examples/forgejo-runner/EdgeConnectConfig_v2.yaml diff --git a/sdk/examples/forgejo-runner/EdgeConnectConfig.yaml b/sdk/examples/forgejo-runner/EdgeConnectConfig_v1.yaml similarity index 100% rename from sdk/examples/forgejo-runner/EdgeConnectConfig.yaml rename to sdk/examples/forgejo-runner/EdgeConnectConfig_v1.yaml diff --git a/sdk/examples/forgejo-runner/EdgeConnectConfig_v2.yaml b/sdk/examples/forgejo-runner/EdgeConnectConfig_v2.yaml new file mode 100644 index 0000000..5afcf4b --- /dev/null +++ b/sdk/examples/forgejo-runner/EdgeConnectConfig_v2.yaml @@ -0,0 +1,29 @@ +# 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" From f3cbfa3723f68e1073e271bb23a658001d056367 Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Wed, 22 Oct 2025 10:31:03 +0200 Subject: [PATCH 14/21] fix(deploy): Fixed glitch when updating an app inst with an invalid manifest --- internal/apply/v2/manager.go | 150 +++++++++++++++++- internal/apply/v2/manager_test.go | 106 +++++++++++++ internal/apply/v2/strategy_recreate.go | 91 +++++++++++ internal/apply/v2/types.go | 27 ++++ .../comprehensive/k8s-deployment.yaml | 1 + 5 files changed, 374 insertions(+), 1 deletion(-) diff --git a/internal/apply/v2/manager.go b/internal/apply/v2/manager.go index 4866129..f43e933 100644 --- a/internal/apply/v2/manager.go +++ b/internal/apply/v2/manager.go @@ -4,7 +4,9 @@ package v2 import ( "context" + "errors" "fmt" + "strings" "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" @@ -204,7 +206,8 @@ func (rm *EdgeConnectResourceManager) RollbackDeployment(ctx context.Context, re rollbackErrors := []error{} - // Rollback completed instances (in reverse order) + // 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] @@ -218,6 +221,32 @@ func (rm *EdgeConnectResourceManager) RollbackDeployment(ctx context.Context, re } } + // 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) } @@ -278,6 +307,125 @@ func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, acti 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 { diff --git a/internal/apply/v2/manager_test.go b/internal/apply/v2/manager_test.go index dd2fc55..6d5ef18 100644 --- a/internal/apply/v2/manager_test.go +++ b/internal/apply/v2/manager_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -464,6 +465,111 @@ func TestRollbackDeploymentFailure(t *testing.T) { 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{ diff --git a/internal/apply/v2/strategy_recreate.go b/internal/apply/v2/strategy_recreate.go index 89c9c56..4d81029 100644 --- a/internal/apply/v2/strategy_recreate.go +++ b/internal/apply/v2/strategy_recreate.go @@ -159,6 +159,19 @@ func (r *RecreateStrategy) deleteInstancesPhase(ctx context.Context, plan *Deplo 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 { @@ -172,6 +185,19 @@ func (r *RecreateStrategy) deleteInstancesPhase(ctx context.Context, plan *Deplo } 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 } @@ -184,6 +210,17 @@ func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentP 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, @@ -516,6 +553,52 @@ func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppActi 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, + }, + } + + instance, err := r.client.ShowAppInstance(ctx, instanceKey, 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 { @@ -530,6 +613,14 @@ func isRetryableError(err error) bool { 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) { diff --git a/internal/apply/v2/types.go b/internal/apply/v2/types.go index ae52420..26d998e 100644 --- a/internal/apply/v2/types.go +++ b/internal/apply/v2/types.go @@ -271,6 +271,12 @@ type ExecutionResult struct { // 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 @@ -294,6 +300,27 @@ type ActionResult struct { 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 { diff --git a/sdk/examples/comprehensive/k8s-deployment.yaml b/sdk/examples/comprehensive/k8s-deployment.yaml index 2a0a741..dff3649 100644 --- a/sdk/examples/comprehensive/k8s-deployment.yaml +++ b/sdk/examples/comprehensive/k8s-deployment.yaml @@ -18,6 +18,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: edgeconnect-coder-deployment + #namespace: gitea spec: replicas: 1 selector: From 9772a072e8b223af95448c008904b8eb6479f2b1 Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Wed, 22 Oct 2025 12:47:15 +0200 Subject: [PATCH 15/21] chore(linting): Fixed all linter errors --- cmd/app.go | 14 +++++++---- cmd/apply.go | 22 ++++-------------- cmd/delete.go | 8 ++++--- cmd/instance.go | 28 ++++++++++++++++------ cmd/root.go | 32 +++++++++++++++++++------- internal/apply/v1/planner.go | 21 ++++++++--------- internal/apply/v2/planner.go | 21 ++++++++--------- sdk/edgeconnect/appinstance.go | 24 ++++++++++++++----- sdk/edgeconnect/appinstance_test.go | 8 +++---- sdk/edgeconnect/apps.go | 24 ++++++++++++++----- sdk/edgeconnect/apps_test.go | 18 ++++----------- sdk/edgeconnect/auth.go | 4 +++- sdk/edgeconnect/auth_test.go | 12 +++++----- sdk/edgeconnect/cloudlet.go | 24 ++++++++++++++----- sdk/edgeconnect/cloudlet_test.go | 10 ++++---- sdk/edgeconnect/v2/appinstance.go | 24 ++++++++++++++----- sdk/edgeconnect/v2/appinstance_test.go | 8 +++---- sdk/edgeconnect/v2/apps.go | 24 ++++++++++++++----- sdk/edgeconnect/v2/apps_test.go | 18 ++++----------- sdk/edgeconnect/v2/auth.go | 4 +++- sdk/edgeconnect/v2/auth_test.go | 12 +++++----- sdk/edgeconnect/v2/cloudlet.go | 24 ++++++++++++++----- sdk/edgeconnect/v2/cloudlet_test.go | 10 ++++---- sdk/internal/http/transport.go | 4 +++- 24 files changed, 240 insertions(+), 158 deletions(-) diff --git a/cmd/app.go b/cmd/app.go index 02125fc..37218bf 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -37,7 +37,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) } @@ -291,12 +291,18 @@ func init() { cmd.Flags().StringVarP(&appName, "name", "n", "", "application name") cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version") cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)") - cmd.MarkFlagRequired("org") - cmd.MarkFlagRequired("region") + if err := cmd.MarkFlagRequired("org"); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired("region"); err != nil { + panic(err) + } } // Add required name flag for specific commands for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} { - cmd.MarkFlagRequired("name") + if err := cmd.MarkFlagRequired("name"); err != nil { + panic(err) + } } } diff --git a/cmd/apply.go b/cmd/apply.go index e2affd0..cf2b37f 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -31,7 +31,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) } @@ -208,20 +208,6 @@ func runApplyV2(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun return displayDeploymentResults(deployResult) } -type deploymentResult interface { - IsSuccess() bool - GetDuration() string - GetCompletedActions() []actionResult - GetFailedActions() []actionResult - GetError() error -} - -type actionResult interface { - GetType() string - GetTarget() string - GetError() error -} - 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 @@ -288,7 +274,7 @@ func displayDeploymentResultsV2(deployResult *applyv2.ExecutionResult) error { 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": @@ -305,5 +291,7 @@ func init() { applyCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without applying them") applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan") - applyCmd.MarkFlagRequired("file") + if err := applyCmd.MarkFlagRequired("file"); err != nil { + panic(err) + } } diff --git a/cmd/delete.go b/cmd/delete.go index 7124e61..dcc1614 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -31,7 +31,7 @@ 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() + _ = cmd.Usage() os.Exit(1) } @@ -273,7 +273,7 @@ func displayDeletionResultsV2(deleteResult *deletev2.DeletionResult) error { func confirmDeletion() bool { fmt.Print("Do you want to proceed with deletion? (yes/no): ") var response string - fmt.Scanln(&response) + _, _ = fmt.Scanln(&response) switch response { case "yes", "y", "YES", "Y": @@ -290,5 +290,7 @@ func init() { 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") - deleteCmd.MarkFlagRequired("file") + if err := deleteCmd.MarkFlagRequired("file"); err != nil { + panic(err) + } } diff --git a/cmd/instance.go b/cmd/instance.go index 0b78986..75868ce 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -230,17 +230,31 @@ func init() { cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)") cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)") - cmd.MarkFlagRequired("org") - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("cloudlet") - cmd.MarkFlagRequired("cloudlet-org") - cmd.MarkFlagRequired("region") + 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) + } } // Add additional flags for create command createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)") createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version") createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)") - createInstanceCmd.MarkFlagRequired("app") - createInstanceCmd.MarkFlagRequired("flavor") + if err := createInstanceCmd.MarkFlagRequired("app"); err != nil { + panic(err) + } + if err := createInstanceCmd.MarkFlagRequired("flavor"); err != nil { + panic(err) + } } diff --git a/cmd/root.go b/cmd/root.go index dd22f72..52ae3ca 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,19 +44,35 @@ func init() { rootCmd.PersistentFlags().StringVar(&apiVersion, "api-version", "v2", "API version to use (v1 or v2)") rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging") - viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")) - viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")) - viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")) - viper.BindPFlag("api_version", rootCmd.PersistentFlags().Lookup("api-version")) + 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) + } } func initConfig() { viper.AutomaticEnv() viper.SetEnvPrefix("EDGE_CONNECT") - viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL") - viper.BindEnv("username", "EDGE_CONNECT_USERNAME") - viper.BindEnv("password", "EDGE_CONNECT_PASSWORD") - viper.BindEnv("api_version", "EDGE_CONNECT_API_VERSION") + 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) + } if cfgFile != "" { viper.SetConfigFile(cfgFile) diff --git a/internal/apply/v1/planner.go b/internal/apply/v1/planner.go index 001076c..bcfd043 100644 --- a/internal/apply/v1/planner.go +++ b/internal/apply/v1/planner.go @@ -323,12 +323,7 @@ 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{ - Protocol: conn.Protocol, - PortRangeMin: conn.PortRangeMin, - PortRangeMax: conn.PortRangeMax, - RemoteCIDR: conn.RemoteCIDR, - } + current.OutboundConnections[i] = SecurityRule(conn) } return current, nil @@ -470,7 +465,9 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string, if err != nil { return "", fmt.Errorf("failed to open manifest file: %w", err) } - defer file.Close() + defer func() { + _ = file.Close() + }() hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { @@ -505,18 +502,20 @@ func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) ti var duration time.Duration // App operations - if plan.AppAction.Type == ActionCreate { + switch plan.AppAction.Type { + case ActionCreate: duration += 30 * time.Second - } else if plan.AppAction.Type == ActionUpdate { + case ActionUpdate: duration += 15 * time.Second } // Instance operations (can be done in parallel) instanceDuration := time.Duration(0) for _, action := range plan.InstanceActions { - if action.Type == ActionCreate { + switch action.Type { + case ActionCreate: instanceDuration = max(instanceDuration, 2*time.Minute) - } else if action.Type == ActionUpdate { + case ActionUpdate: instanceDuration = max(instanceDuration, 1*time.Minute) } } diff --git a/internal/apply/v2/planner.go b/internal/apply/v2/planner.go index 52a5e18..61f15cd 100644 --- a/internal/apply/v2/planner.go +++ b/internal/apply/v2/planner.go @@ -323,12 +323,7 @@ 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{ - Protocol: conn.Protocol, - PortRangeMin: conn.PortRangeMin, - PortRangeMax: conn.PortRangeMax, - RemoteCIDR: conn.RemoteCIDR, - } + current.OutboundConnections[i] = SecurityRule(conn) } return current, nil @@ -470,7 +465,9 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string, if err != nil { return "", fmt.Errorf("failed to open manifest file: %w", err) } - defer file.Close() + defer func() { + _ = file.Close() + }() hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { @@ -505,18 +502,20 @@ func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) ti var duration time.Duration // App operations - if plan.AppAction.Type == ActionCreate { + switch plan.AppAction.Type { + case ActionCreate: duration += 30 * time.Second - } else if plan.AppAction.Type == ActionUpdate { + case ActionUpdate: duration += 15 * time.Second } // Instance operations (can be done in parallel) instanceDuration := time.Duration(0) for _, action := range plan.InstanceActions { - if action.Type == ActionCreate { + switch action.Type { + case ActionCreate: instanceDuration = max(instanceDuration, 2*time.Minute) - } else if action.Type == ActionUpdate { + case ActionUpdate: instanceDuration = max(instanceDuration, 1*time.Minute) } } diff --git a/sdk/edgeconnect/appinstance.go b/sdk/edgeconnect/appinstance.go index f655c98..4a1bda9 100644 --- a/sdk/edgeconnect/appinstance.go +++ b/sdk/edgeconnect/appinstance.go @@ -23,7 +23,9 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp if err != nil { return fmt.Errorf("CreateAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "CreateAppInstance") @@ -56,7 +58,9 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, if err != nil { return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return AppInstance{}, fmt.Errorf("app instance %s/%s: %w", @@ -96,7 +100,9 @@ func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey if err != nil { return nil, fmt.Errorf("ShowAppInstances failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { return nil, c.handleErrorResponse(resp, "ShowAppInstances") @@ -125,7 +131,9 @@ func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstance if err != nil { return fmt.Errorf("UpdateAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "UpdateAppInstance") @@ -152,7 +160,9 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK if err != nil { return fmt.Errorf("RefreshAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "RefreshAppInstance") @@ -179,7 +189,9 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe if err != nil { return fmt.Errorf("DeleteAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // 404 is acceptable for delete operations (already deleted) if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { diff --git a/sdk/edgeconnect/appinstance_test.go b/sdk/edgeconnect/appinstance_test.go index ac9c1eb..003f024 100644 --- a/sdk/edgeconnect/appinstance_test.go +++ b/sdk/edgeconnect/appinstance_test.go @@ -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() @@ -207,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() @@ -254,7 +254,7 @@ 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() @@ -361,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() diff --git a/sdk/edgeconnect/apps.go b/sdk/edgeconnect/apps.go index 8973862..f197a68 100644 --- a/sdk/edgeconnect/apps.go +++ b/sdk/edgeconnect/apps.go @@ -28,7 +28,9 @@ func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error { if err != nil { return fmt.Errorf("CreateApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "CreateApp") @@ -55,7 +57,9 @@ func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App if err != nil { return App{}, fmt.Errorf("ShowApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", @@ -95,7 +99,9 @@ func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([] if err != nil { return nil, fmt.Errorf("ShowApps failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { return nil, c.handleErrorResponse(resp, "ShowApps") @@ -124,7 +130,9 @@ func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error { if err != nil { return fmt.Errorf("UpdateApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "UpdateApp") @@ -151,7 +159,9 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er if err != nil { return fmt.Errorf("DeleteApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // 404 is acceptable for delete operations (already deleted) if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { @@ -238,7 +248,9 @@ func (c *Client) handleErrorResponse(resp *http.Response, operation string) erro bodyBytes := []byte{} if resp.Body != nil { - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() bodyBytes, _ = io.ReadAll(resp.Body) messages = append(messages, string(bodyBytes)) } diff --git a/sdk/edgeconnect/apps_test.go b/sdk/edgeconnect/apps_test.go index 30531f6..88437ca 100644 --- a/sdk/edgeconnect/apps_test.go +++ b/sdk/edgeconnect/apps_test.go @@ -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,13 +407,3 @@ 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")) - } - })) -} diff --git a/sdk/edgeconnect/auth.go b/sdk/edgeconnect/auth.go index eab24b9..cf6067b 100644 --- a/sdk/edgeconnect/auth.go +++ b/sdk/edgeconnect/auth.go @@ -138,7 +138,9 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e if err != nil { return "", err } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // Read response body - same as existing implementation body, err := io.ReadAll(resp.Body) diff --git a/sdk/edgeconnect/auth_test.go b/sdk/edgeconnect/auth_test.go index 8ea3176..8e68dc4 100644 --- a/sdk/edgeconnect/auth_test.go +++ b/sdk/edgeconnect/auth_test.go @@ -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() diff --git a/sdk/edgeconnect/cloudlet.go b/sdk/edgeconnect/cloudlet.go index 0ed6e71..142b9d6 100644 --- a/sdk/edgeconnect/cloudlet.go +++ b/sdk/edgeconnect/cloudlet.go @@ -22,7 +22,9 @@ func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) er if err != nil { return fmt.Errorf("CreateCloudlet failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "CreateCloudlet") @@ -49,7 +51,9 @@ func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, regi if err != nil { return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", @@ -89,7 +93,9 @@ func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, reg if err != nil { return nil, fmt.Errorf("ShowCloudlets failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { return nil, c.handleErrorResponse(resp, "ShowCloudlets") @@ -123,7 +129,9 @@ func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, re if err != nil { return fmt.Errorf("DeleteCloudlet failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // 404 is acceptable for delete operations (already deleted) if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { @@ -151,7 +159,9 @@ func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKe if err != nil { return nil, fmt.Errorf("GetCloudletManifest failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w", @@ -189,7 +199,9 @@ func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey Cloud if err != nil { return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w", diff --git a/sdk/edgeconnect/cloudlet_test.go b/sdk/edgeconnect/cloudlet_test.go index 7d129bb..b029f17 100644 --- a/sdk/edgeconnect/cloudlet_test.go +++ b/sdk/edgeconnect/cloudlet_test.go @@ -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() diff --git a/sdk/edgeconnect/v2/appinstance.go b/sdk/edgeconnect/v2/appinstance.go index d38821e..f7b04bb 100644 --- a/sdk/edgeconnect/v2/appinstance.go +++ b/sdk/edgeconnect/v2/appinstance.go @@ -25,7 +25,9 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp if err != nil { return fmt.Errorf("CreateAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "CreateAppInstance") @@ -58,7 +60,9 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, if err != nil { return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return AppInstance{}, fmt.Errorf("app instance %s/%s: %w", @@ -98,7 +102,9 @@ func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey if err != nil { return nil, fmt.Errorf("ShowAppInstances failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { return nil, c.handleErrorResponse(resp, "ShowAppInstances") @@ -127,7 +133,9 @@ func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstance if err != nil { return fmt.Errorf("UpdateAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "UpdateAppInstance") @@ -154,7 +162,9 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK if err != nil { return fmt.Errorf("RefreshAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "RefreshAppInstance") @@ -181,7 +191,9 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe if err != nil { return fmt.Errorf("DeleteAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // 404 is acceptable for delete operations (already deleted) if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { diff --git a/sdk/edgeconnect/v2/appinstance_test.go b/sdk/edgeconnect/v2/appinstance_test.go index e1c3d5e..dd0bc45 100644 --- a/sdk/edgeconnect/v2/appinstance_test.go +++ b/sdk/edgeconnect/v2/appinstance_test.go @@ -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() @@ -207,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() @@ -254,7 +254,7 @@ 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() @@ -361,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() diff --git a/sdk/edgeconnect/v2/apps.go b/sdk/edgeconnect/v2/apps.go index 8f5410e..80c3981 100644 --- a/sdk/edgeconnect/v2/apps.go +++ b/sdk/edgeconnect/v2/apps.go @@ -29,7 +29,9 @@ func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error { if err != nil { return fmt.Errorf("CreateApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "CreateApp") @@ -56,7 +58,9 @@ func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App if err != nil { return App{}, fmt.Errorf("ShowApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", @@ -96,7 +100,9 @@ func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([] if err != nil { return nil, fmt.Errorf("ShowApps failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { return nil, c.handleErrorResponse(resp, "ShowApps") @@ -125,7 +131,9 @@ func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error { if err != nil { return fmt.Errorf("UpdateApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "UpdateApp") @@ -152,7 +160,9 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er if err != nil { return fmt.Errorf("DeleteApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // 404 is acceptable for delete operations (already deleted) if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { @@ -254,7 +264,9 @@ func (c *Client) handleErrorResponse(resp *http.Response, operation string) erro bodyBytes := []byte{} if resp.Body != nil { - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() bodyBytes, _ = io.ReadAll(resp.Body) messages = append(messages, string(bodyBytes)) } diff --git a/sdk/edgeconnect/v2/apps_test.go b/sdk/edgeconnect/v2/apps_test.go index 4ea757c..a4c202f 100644 --- a/sdk/edgeconnect/v2/apps_test.go +++ b/sdk/edgeconnect/v2/apps_test.go @@ -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,13 +407,3 @@ 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")) - } - })) -} diff --git a/sdk/edgeconnect/v2/auth.go b/sdk/edgeconnect/v2/auth.go index a1f33a2..f428f64 100644 --- a/sdk/edgeconnect/v2/auth.go +++ b/sdk/edgeconnect/v2/auth.go @@ -138,7 +138,9 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e if err != nil { return "", err } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // Read response body - same as existing implementation body, err := io.ReadAll(resp.Body) diff --git a/sdk/edgeconnect/v2/auth_test.go b/sdk/edgeconnect/v2/auth_test.go index 0fc5b24..34ebcaf 100644 --- a/sdk/edgeconnect/v2/auth_test.go +++ b/sdk/edgeconnect/v2/auth_test.go @@ -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() diff --git a/sdk/edgeconnect/v2/cloudlet.go b/sdk/edgeconnect/v2/cloudlet.go index 415584a..c877486 100644 --- a/sdk/edgeconnect/v2/cloudlet.go +++ b/sdk/edgeconnect/v2/cloudlet.go @@ -22,7 +22,9 @@ func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) er if err != nil { return fmt.Errorf("CreateCloudlet failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "CreateCloudlet") @@ -49,7 +51,9 @@ func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, regi if err != nil { return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", @@ -89,7 +93,9 @@ func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, reg if err != nil { return nil, fmt.Errorf("ShowCloudlets failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { return nil, c.handleErrorResponse(resp, "ShowCloudlets") @@ -123,7 +129,9 @@ func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, re if err != nil { return fmt.Errorf("DeleteCloudlet failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // 404 is acceptable for delete operations (already deleted) if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { @@ -151,7 +159,9 @@ func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKe if err != nil { return nil, fmt.Errorf("GetCloudletManifest failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w", @@ -189,7 +199,9 @@ func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey Cloud if err != nil { return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w", diff --git a/sdk/edgeconnect/v2/cloudlet_test.go b/sdk/edgeconnect/v2/cloudlet_test.go index 8f2cc06..d8ffb75 100644 --- a/sdk/edgeconnect/v2/cloudlet_test.go +++ b/sdk/edgeconnect/v2/cloudlet_test.go @@ -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() diff --git a/sdk/internal/http/transport.go b/sdk/internal/http/transport.go index c3bbab1..35b71b8 100644 --- a/sdk/internal/http/transport.go +++ b/sdk/internal/http/transport.go @@ -162,7 +162,9 @@ func (t *Transport) CallJSON(ctx context.Context, method, url string, body inter if err != nil { return resp, err } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // Read response body respBody, err := io.ReadAll(resp.Body) From ece3dddfe6e014529efd86c59fdbb2c5d0193e4c Mon Sep 17 00:00:00 2001 From: Richard Robert Reitz Date: Mon, 27 Oct 2025 16:32:57 +0100 Subject: [PATCH 16/21] feat(edge): Added ubuntu buildkit edge v1 (running) and v2 (not running) example --- .../ubuntu-buildkit/EdgeConnectConfig_v1.yaml | 29 ++++++++++ .../ubuntu-buildkit/EdgeConnectConfig_v2.yaml | 29 ++++++++++ .../ubuntu-buildkit/k8s-deployment.yaml | 57 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v1.yaml create mode 100644 sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v2.yaml create mode 100644 sdk/examples/ubuntu-buildkit/k8s-deployment.yaml diff --git a/sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v1.yaml b/sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v1.yaml new file mode 100644 index 0000000..9710327 --- /dev/null +++ b/sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v1.yaml @@ -0,0 +1,29 @@ +# 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" diff --git a/sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v2.yaml b/sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v2.yaml new file mode 100644 index 0000000..9fb80df --- /dev/null +++ b/sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v2.yaml @@ -0,0 +1,29 @@ +# 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" diff --git a/sdk/examples/ubuntu-buildkit/k8s-deployment.yaml b/sdk/examples/ubuntu-buildkit/k8s-deployment.yaml new file mode 100644 index 0000000..d4d3dd8 --- /dev/null +++ b/sdk/examples/ubuntu-buildkit/k8s-deployment.yaml @@ -0,0 +1,57 @@ +# 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 From a51e2ae4541a72df74bd814c4984936e89812448 Mon Sep 17 00:00:00 2001 From: Patrick Sy Date: Thu, 13 Nov 2025 15:40:36 +0100 Subject: [PATCH 17/21] feat(api): Added AppKey property to ShowAppInstances --- cmd/instance.go | 8 ++++++-- internal/apply/v1/planner.go | 7 +++++-- internal/apply/v1/planner_test.go | 10 +--------- internal/apply/v2/planner.go | 7 +++++-- internal/apply/v2/planner_test.go | 2 +- internal/apply/v2/strategy_recreate.go | 4 +++- sdk/edgeconnect/appinstance.go | 4 ++-- sdk/edgeconnect/appinstance_test.go | 5 ++++- sdk/edgeconnect/v2/appinstance.go | 2 +- sdk/edgeconnect/v2/appinstance_test.go | 5 ++++- sdk/examples/comprehensive/main.go | 6 +++--- 11 files changed, 35 insertions(+), 25 deletions(-) diff --git a/cmd/instance.go b/cmd/instance.go index 75868ce..68c8f5b 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -15,6 +15,7 @@ var ( cloudletOrg string instanceName string flavorName string + appId string ) var appInstanceCmd = &cobra.Command{ @@ -104,7 +105,8 @@ var showInstanceCmd = &cobra.Command{ Name: cloudletName, }, } - instance, err := c.ShowAppInstance(context.Background(), instanceKey, region) + 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) @@ -120,7 +122,8 @@ var showInstanceCmd = &cobra.Command{ Name: cloudletName, }, } - instance, err := c.ShowAppInstance(context.Background(), instanceKey, region) + 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) @@ -229,6 +232,7 @@ 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) diff --git a/internal/apply/v1/planner.go b/internal/apply/v1/planner.go index bcfd043..e1a1449 100644 --- a/internal/apply/v1/planner.go +++ b/internal/apply/v1/planner.go @@ -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, region string) (edgeconnect.AppInstance, error) + ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, 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 @@ -342,8 +342,11 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire Name: desired.CloudletName, }, } + appKey := edgeconnect.AppKey{ + Name: desired.AppName, + } - instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region) + instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region) if err != nil { return nil, err } diff --git a/internal/apply/v1/planner_test.go b/internal/apply/v1/planner_test.go index 7761365..6530d8e 100644 --- a/internal/apply/v1/planner_test.go +++ b/internal/apply/v1/planner_test.go @@ -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, region string) (edgeconnect.AppInstance, error) { +func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error) { args := m.Called(ctx, instanceKey, region) if args.Get(0) == nil { return edgeconnect.AppInstance{}, args.Error(1) @@ -75,14 +75,6 @@ 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) diff --git a/internal/apply/v2/planner.go b/internal/apply/v2/planner.go index 61f15cd..33a809e 100644 --- a/internal/apply/v2/planner.go +++ b/internal/apply/v2/planner.go @@ -21,7 +21,7 @@ type EdgeConnectClientInterface interface { 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, region string) (v2.AppInstance, 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 @@ -343,7 +343,10 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire }, } - instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region) + appKey := v2.AppKey{ Name: desired.AppName} + + + instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region) if err != nil { return nil, err } diff --git a/internal/apply/v2/planner_test.go b/internal/apply/v2/planner_test.go index 20d3dab..3fbdbc3 100644 --- a/internal/apply/v2/planner_test.go +++ b/internal/apply/v2/planner_test.go @@ -29,7 +29,7 @@ func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, r return args.Get(0).(v2.App), args.Error(1) } -func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) (v2.AppInstance, error) { +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) diff --git a/internal/apply/v2/strategy_recreate.go b/internal/apply/v2/strategy_recreate.go index 4d81029..17ea3a7 100644 --- a/internal/apply/v2/strategy_recreate.go +++ b/internal/apply/v2/strategy_recreate.go @@ -586,7 +586,9 @@ func (r *RecreateStrategy) backupInstance(ctx context.Context, action InstanceAc }, } - instance, err := r.client.ShowAppInstance(ctx, instanceKey, action.Target.Region) + 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) } diff --git a/sdk/edgeconnect/appinstance.go b/sdk/edgeconnect/appinstance.go index 4a1bda9..9e73511 100644 --- a/sdk/edgeconnect/appinstance.go +++ b/sdk/edgeconnect/appinstance.go @@ -45,12 +45,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, region string) (AppInstance, error) { +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}, + AppInstance: AppInstance{AppKey: appKey, Key: appInstKey}, Region: region, } diff --git a/sdk/edgeconnect/appinstance_test.go b/sdk/edgeconnect/appinstance_test.go index 003f024..210c5e7 100644 --- a/sdk/edgeconnect/appinstance_test.go +++ b/sdk/edgeconnect/appinstance_test.go @@ -156,6 +156,7 @@ func TestCreateAppInstance(t *testing.T) { func TestShowAppInstance(t *testing.T) { tests := []struct { name string + appKey AppKey appInstKey AppInstanceKey region string mockStatusCode int @@ -173,6 +174,7 @@ 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"}} @@ -190,6 +192,7 @@ func TestShowAppInstance(t *testing.T) { Name: "testcloudlet", }, }, + appKey: AppKey{Name: "test-app-id"}, region: "us-west", mockStatusCode: 404, mockResponse: "", @@ -219,7 +222,7 @@ func TestShowAppInstance(t *testing.T) { // Execute test ctx := context.Background() - appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region) + appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region) // Verify results if tt.expectError { diff --git a/sdk/edgeconnect/v2/appinstance.go b/sdk/edgeconnect/v2/appinstance.go index f7b04bb..bd27be7 100644 --- a/sdk/edgeconnect/v2/appinstance.go +++ b/sdk/edgeconnect/v2/appinstance.go @@ -47,7 +47,7 @@ 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, region string) (AppInstance, error) { +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" diff --git a/sdk/edgeconnect/v2/appinstance_test.go b/sdk/edgeconnect/v2/appinstance_test.go index dd0bc45..ce4e758 100644 --- a/sdk/edgeconnect/v2/appinstance_test.go +++ b/sdk/edgeconnect/v2/appinstance_test.go @@ -157,6 +157,7 @@ func TestShowAppInstance(t *testing.T) { tests := []struct { name string appInstKey AppInstanceKey + appKey AppKey region string mockStatusCode int mockResponse string @@ -173,6 +174,7 @@ func TestShowAppInstance(t *testing.T) { 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"}} @@ -190,6 +192,7 @@ func TestShowAppInstance(t *testing.T) { Name: "testcloudlet", }, }, + appKey: AppKey{ Name: "testapp" }, region: "us-west", mockStatusCode: 404, mockResponse: "", @@ -219,7 +222,7 @@ func TestShowAppInstance(t *testing.T) { // Execute test ctx := context.Background() - appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region) + appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region) // Verify results if tt.expectError { diff --git a/sdk/examples/comprehensive/main.go b/sdk/examples/comprehensive/main.go index f932a75..0bc6e51 100644 --- a/sdk/examples/comprehensive/main.go +++ b/sdk/examples/comprehensive/main.go @@ -193,7 +193,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow }, } - instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, config.Region, 5*time.Minute) + instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, v2.AppKey{}, config.Region, 5*time.Minute) if err != nil { return fmt.Errorf("failed to wait for instance ready: %w", 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, region string, timeout time.Duration) (v2.AppInstance, error) { +func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string, timeout time.Duration) (v2.AppInstance, error) { timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -321,7 +321,7 @@ func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppI return v2.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout) case <-ticker.C: - instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, region) + instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, appKey, region) if err != nil { // Log error but continue polling fmt.Printf(" ⚠️ Error checking instance state: %v\n", err) From ece2955a2a427e85caaf739f837468e2cb128e2b Mon Sep 17 00:00:00 2001 From: Patrick Sy Date: Thu, 13 Nov 2025 16:59:38 +0100 Subject: [PATCH 18/21] feat(api): Added AppKey to ShowAppInstances --- cmd/instance.go | 6 ++++-- internal/delete/v1/planner.go | 5 +++-- internal/delete/v2/manager_test.go | 2 +- internal/delete/v2/planner.go | 5 +++-- internal/delete/v2/planner_test.go | 2 +- sdk/edgeconnect/appinstance.go | 4 ++-- sdk/edgeconnect/appinstance_test.go | 2 +- sdk/edgeconnect/v2/appinstance.go | 4 ++-- sdk/edgeconnect/v2/appinstance_test.go | 2 +- sdk/examples/comprehensive/main.go | 2 +- 10 files changed, 19 insertions(+), 15 deletions(-) diff --git a/cmd/instance.go b/cmd/instance.go index 68c8f5b..d856dea 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -149,7 +149,8 @@ var listInstancesCmd = &cobra.Command{ Name: cloudletName, }, } - instances, err := c.ShowAppInstances(context.Background(), instanceKey, region) + 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) @@ -168,7 +169,8 @@ var listInstancesCmd = &cobra.Command{ Name: cloudletName, }, } - instances, err := c.ShowAppInstances(context.Background(), instanceKey, region) + 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) diff --git a/internal/delete/v1/planner.go b/internal/delete/v1/planner.go index 10f41c5..ca97b84 100644 --- a/internal/delete/v1/planner.go +++ b/internal/delete/v1/planner.go @@ -14,7 +14,7 @@ import ( // 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, region string) ([]edgeconnect.AppInstance, 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 } @@ -154,8 +154,9 @@ func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config * Name: infra.CloudletName, }, } + appKey := edgeconnect.AppKey{Name: config.Metadata.Name} - instances, err := p.client.ShowAppInstances(ctx, instanceKey, infra.Region) + 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) { diff --git a/internal/delete/v2/manager_test.go b/internal/delete/v2/manager_test.go index fa2b7c9..d021f20 100644 --- a/internal/delete/v2/manager_test.go +++ b/internal/delete/v2/manager_test.go @@ -27,7 +27,7 @@ func (m *MockResourceClient) ShowApp(ctx context.Context, appKey v2.AppKey, regi return args.Get(0).(v2.App), args.Error(1) } -func (m *MockResourceClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) { +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) diff --git a/internal/delete/v2/planner.go b/internal/delete/v2/planner.go index 752fe3b..76ec1c6 100644 --- a/internal/delete/v2/planner.go +++ b/internal/delete/v2/planner.go @@ -14,7 +14,7 @@ import ( // 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, region string) ([]v2.AppInstance, 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 } @@ -154,8 +154,9 @@ func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config * Name: infra.CloudletName, }, } + appKey := v2.AppKey{Name: config.Metadata.Name} - instances, err := p.client.ShowAppInstances(ctx, instanceKey, infra.Region) + 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) { diff --git a/internal/delete/v2/planner_test.go b/internal/delete/v2/planner_test.go index 2ec9eae..292cecc 100644 --- a/internal/delete/v2/planner_test.go +++ b/internal/delete/v2/planner_test.go @@ -28,7 +28,7 @@ func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, r return args.Get(0).(v2.App), args.Error(1) } -func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) { +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) diff --git a/sdk/edgeconnect/appinstance.go b/sdk/edgeconnect/appinstance.go index 9e73511..2a6673c 100644 --- a/sdk/edgeconnect/appinstance.go +++ b/sdk/edgeconnect/appinstance.go @@ -87,12 +87,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, region string) ([]AppInstance, error) { +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}, + AppInstance: AppInstance{Key: appInstKey, AppKey: appKey}, Region: region, } diff --git a/sdk/edgeconnect/appinstance_test.go b/sdk/edgeconnect/appinstance_test.go index 210c5e7..3545904 100644 --- a/sdk/edgeconnect/appinstance_test.go +++ b/sdk/edgeconnect/appinstance_test.go @@ -264,7 +264,7 @@ func TestShowAppInstances(t *testing.T) { client := NewClient(server.URL) ctx := context.Background() - appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, "us-west") + appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west") require.NoError(t, err) assert.Len(t, appInstances, 2) diff --git a/sdk/edgeconnect/v2/appinstance.go b/sdk/edgeconnect/v2/appinstance.go index bd27be7..013d053 100644 --- a/sdk/edgeconnect/v2/appinstance.go +++ b/sdk/edgeconnect/v2/appinstance.go @@ -89,12 +89,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, region string) ([]AppInstance, error) { +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}, + AppInstance: AppInstance{Key: appInstKey, AppKey: appKey}, Region: region, } diff --git a/sdk/edgeconnect/v2/appinstance_test.go b/sdk/edgeconnect/v2/appinstance_test.go index ce4e758..bf4db81 100644 --- a/sdk/edgeconnect/v2/appinstance_test.go +++ b/sdk/edgeconnect/v2/appinstance_test.go @@ -264,7 +264,7 @@ func TestShowAppInstances(t *testing.T) { client := NewClient(server.URL) ctx := context.Background() - appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, "us-west") + appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west") require.NoError(t, err) assert.Len(t, appInstances, 2) diff --git a/sdk/examples/comprehensive/main.go b/sdk/examples/comprehensive/main.go index 0bc6e51..25a4aa5 100644 --- a/sdk/examples/comprehensive/main.go +++ b/sdk/examples/comprehensive/main.go @@ -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}, config.Region) + instances, err := c.ShowAppInstances(ctx, v2.AppInstanceKey{Organization: config.Organization}, v2.AppKey{}, config.Region) if err != nil { return fmt.Errorf("failed to list app instances: %w", err) } From 2909e0d1b4c0a98d402e87b901b72aa127ccfc28 Mon Sep 17 00:00:00 2001 From: Martin McCaffery Date: Fri, 14 Nov 2025 12:11:24 +0100 Subject: [PATCH 19/21] feat(api): add nicer error message to format issues indicating permission denied --- sdk/edgeconnect/appinstance.go | 4 ++++ sdk/edgeconnect/apps.go | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/sdk/edgeconnect/appinstance.go b/sdk/edgeconnect/appinstance.go index 2a6673c..34e3486 100644 --- a/sdk/edgeconnect/appinstance.go +++ b/sdk/edgeconnect/appinstance.go @@ -213,6 +213,10 @@ 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 != "" { diff --git a/sdk/edgeconnect/apps.go b/sdk/edgeconnect/apps.go index f197a68..a086475 100644 --- a/sdk/edgeconnect/apps.go +++ b/sdk/edgeconnect/apps.go @@ -15,7 +15,8 @@ import ( var ( // ErrResourceNotFound indicates the requested resource was not found - ErrResourceNotFound = fmt.Errorf("resource not found") + ErrResourceNotFound = fmt.Errorf("resource not found") + ErrFaultyResponsePerhaps403 = fmt.Errorf("faulty response from API, may indicate permission denied") ) // CreateApp creates a new application in the specified region @@ -179,6 +180,10 @@ 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 From e38d7e84d52b4fb9fc8e8d6ec40b540ef1f81b16 Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Fri, 14 Nov 2025 16:00:43 +0100 Subject: [PATCH 20/21] parseStreamingResponse is now unified for all objects under both versions --- .gitignore | 2 + Makefile | 2 +- internal/apply/v2/manager.go | 20 ++-- internal/apply/v2/planner.go | 3 +- internal/apply/v2/strategy_recreate.go | 2 +- internal/config/example_test.go | 6 +- internal/delete/v2/types_test.go | 4 +- sdk/edgeconnect/types.go | 134 ++++++++++----------- sdk/edgeconnect/v2/appinstance.go | 158 ++++++++++++------------- sdk/edgeconnect/v2/appinstance_test.go | 4 +- sdk/edgeconnect/v2/apps.go | 74 +----------- sdk/edgeconnect/v2/types.go | 147 ++++++++++++----------- 12 files changed, 250 insertions(+), 306 deletions(-) diff --git a/.gitignore b/.gitignore index c08c1df..dec973c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist/ ### direnv ### .direnv .envrc + +edge-connect-client diff --git a/Makefile b/Makefile index 496876e..a8695c5 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ clean: # Lint the code lint: - golangci-lint run + go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2 run # Run all checks (generate, test, lint) check: test lint diff --git a/internal/apply/v2/manager.go b/internal/apply/v2/manager.go index f43e933..9bce91f 100644 --- a/internal/apply/v2/manager.go +++ b/internal/apply/v2/manager.go @@ -317,16 +317,16 @@ func (rm *EdgeConnectResourceManager) restoreApp(ctx context.Context, backup *Ap 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, + 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. }, } diff --git a/internal/apply/v2/planner.go b/internal/apply/v2/planner.go index 33a809e..797a411 100644 --- a/internal/apply/v2/planner.go +++ b/internal/apply/v2/planner.go @@ -343,8 +343,7 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire }, } - appKey := v2.AppKey{ Name: desired.AppName} - + appKey := v2.AppKey{Name: desired.AppName} instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region) if err != nil { diff --git a/internal/apply/v2/strategy_recreate.go b/internal/apply/v2/strategy_recreate.go index 17ea3a7..6af0a68 100644 --- a/internal/apply/v2/strategy_recreate.go +++ b/internal/apply/v2/strategy_recreate.go @@ -586,7 +586,7 @@ func (r *RecreateStrategy) backupInstance(ctx context.Context, action InstanceAc }, } - appKey := v2.AppKey{ Name: action.Desired.AppName } + appKey := v2.AppKey{Name: action.Desired.AppName} instance, err := r.client.ShowAppInstance(ctx, instanceKey, appKey, action.Target.Region) if err != nil { diff --git a/internal/config/example_test.go b/internal/config/example_test.go index 536399f..f7299c2 100644 --- a/internal/config/example_test.go +++ b/internal/config/example_test.go @@ -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{ { diff --git a/internal/delete/v2/types_test.go b/internal/delete/v2/types_test.go index 8dfa6b0..225c5ef 100644 --- a/internal/delete/v2/types_test.go +++ b/internal/delete/v2/types_test.go @@ -16,8 +16,8 @@ func TestDeletionPlan_IsEmpty(t *testing.T) { { name: "empty plan with no resources", plan: &DeletionPlan{ - ConfigName: "test-config", - AppToDelete: nil, + ConfigName: "test-config", + AppToDelete: nil, InstancesToDelete: []InstanceDeletion{}, }, expected: true, diff --git a/sdk/edgeconnect/types.go b/sdk/edgeconnect/types.go index 7fd39fc..307ed52 100644 --- a/sdk/edgeconnect/types.go +++ b/sdk/edgeconnect/types.go @@ -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 diff --git a/sdk/edgeconnect/v2/appinstance.go b/sdk/edgeconnect/v2/appinstance.go index 013d053..eda3467 100644 --- a/sdk/edgeconnect/v2/appinstance.go +++ b/sdk/edgeconnect/v2/appinstance.go @@ -10,8 +10,7 @@ import ( "fmt" "io" "net/http" - - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http" + "strings" ) // CreateAppInstance creates a new application instance in the specified region @@ -34,8 +33,7 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp } // Parse streaming JSON response - var appInstances []AppInstance - if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + if _, err = parseStreamingResponse[AppInstance](resp); err != nil { return fmt.Errorf("ShowAppInstance failed to parse response: %w", err) } @@ -75,7 +73,7 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, // Parse streaming JSON response var appInstances []AppInstance - if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + if appInstances, err = parseStreamingResponse[AppInstance](resp); err != nil { return AppInstance{}, fmt.Errorf("ShowAppInstance failed to parse response: %w", err) } @@ -110,12 +108,12 @@ func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey return nil, c.handleErrorResponse(resp, "ShowAppInstances") } - var appInstances []AppInstance if resp.StatusCode == http.StatusNotFound { - return appInstances, nil // Return empty slice for not found + return []AppInstance{}, nil // Return empty slice for not found } - if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + var appInstances []AppInstance + if appInstances, err = parseStreamingResponse[AppInstance](resp); err != nil { return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err) } @@ -207,88 +205,90 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe } // parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances -func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error { +func parseStreamingResponse[T Message](resp *http.Response) ([]T, error) { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("failed to read response body: %w", err) + return []T{}, fmt.Errorf("failed to read response body: %w", err) } - // Try parsing as a direct JSON array first (v2 API format) - switch v := result.(type) { - case *[]AppInstance: - var appInstances []AppInstance - if err := json.Unmarshal(bodyBytes, &appInstances); err == nil { - *v = appInstances - return nil - } + // 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) } - // Fall back to streaming format (v1 API format) - var appInstances []AppInstance - var messages []string - var hasError bool - var errorCode int - var errorMessage string - - parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { - // Try parsing as ResultResponse first (error format) - var resultResp ResultResponse - if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" { - if resultResp.IsError() { - hasError = true - errorCode = resultResp.GetCode() - errorMessage = resultResp.GetMessage() - } - return nil + if isV2 { + resultV2, err := parseStreamingResponseV2[T](resp.StatusCode, bodyBytes) + if err != nil { + return []T{}, err } - - // Try parsing as Response[AppInstance] - var response Response[AppInstance] - if err := json.Unmarshal(line, &response); err != nil { - return err - } - - if response.HasData() { - appInstances = append(appInstances, response.Data) - } - if response.IsMessage() { - msg := response.Data.GetMessage() - messages = append(messages, msg) - // Check for error indicators in messages - if msg == "CreateError" || msg == "UpdateError" || msg == "DeleteError" { - hasError = true - } - } - return nil - }) - - if parseErr != nil { - return parseErr + return resultV2, nil } - // If we detected an error, return it - if hasError { - apiErr := &APIError{ - StatusCode: resp.StatusCode, - Messages: messages, - } - if errorCode > 0 { - apiErr.StatusCode = errorCode - apiErr.Code = fmt.Sprintf("%d", errorCode) - } - if errorMessage != "" { - apiErr.Messages = append([]string{errorMessage}, apiErr.Messages...) - } - return apiErr + resultV1, err := parseStreamingResponseV1[T](resp.StatusCode, bodyBytes) + if err != nil { + return nil, err } - // Set result based on type - switch v := result.(type) { - case *[]AppInstance: - *v = appInstances - default: - return fmt.Errorf("unsupported result type: %T", result) + if !resultV1.IsSuccessful() { + return []T{}, resultV1.Error() } - return nil + 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 + // Try parsing as a direct JSON array first (v2 API format) + if err := json.Unmarshal(bodyBytes, &result); err == nil { + return result, fmt.Errorf("failed to read response body: %w", err) + } + + return result, nil } diff --git a/sdk/edgeconnect/v2/appinstance_test.go b/sdk/edgeconnect/v2/appinstance_test.go index bf4db81..04df669 100644 --- a/sdk/edgeconnect/v2/appinstance_test.go +++ b/sdk/edgeconnect/v2/appinstance_test.go @@ -174,7 +174,7 @@ func TestShowAppInstance(t *testing.T) { Name: "testcloudlet", }, }, - appKey: AppKey{ Name: "testapp" }, + appKey: AppKey{Name: "testapp"}, region: "us-west", mockStatusCode: 200, mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}} @@ -192,7 +192,7 @@ func TestShowAppInstance(t *testing.T) { Name: "testcloudlet", }, }, - appKey: AppKey{ Name: "testapp" }, + appKey: AppKey{Name: "testapp"}, region: "us-west", mockStatusCode: 404, mockResponse: "", diff --git a/sdk/edgeconnect/v2/apps.go b/sdk/edgeconnect/v2/apps.go index 80c3981..61c1f4c 100644 --- a/sdk/edgeconnect/v2/apps.go +++ b/sdk/edgeconnect/v2/apps.go @@ -4,9 +4,7 @@ package v2 import ( - "bytes" "context" - "encoding/json" "fmt" "io" "net/http" @@ -73,7 +71,7 @@ func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App // Parse streaming JSON response var apps []App - if err := c.parseStreamingResponse(resp, &apps); err != nil { + if apps, err = parseStreamingResponse[App](resp); err != nil { return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err) } @@ -108,12 +106,12 @@ func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([] return nil, c.handleErrorResponse(resp, "ShowApps") } - var apps []App if resp.StatusCode == http.StatusNotFound { - return apps, nil // Return empty slice for not found + return []App{}, nil // Return empty slice for not found } - if err := c.parseStreamingResponse(resp, &apps); err != nil { + var apps []App + if apps, err = parseStreamingResponse[App](resp); err != nil { return nil, fmt.Errorf("ShowApps failed to parse response: %w", err) } @@ -175,70 +173,6 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er return nil } -// parseStreamingResponse parses the EdgeXR streaming JSON response format -func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error { - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - // Try parsing as a direct JSON array first (v2 API format) - switch v := result.(type) { - case *[]App: - var apps []App - if err := json.Unmarshal(bodyBytes, &apps); err == nil { - *v = apps - return nil - } - } - - // Fall back to streaming format (v1 API format) - var responses []Response[App] - var apps []App - var messages []string - - parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { - var response Response[App] - if err := json.Unmarshal(line, &response); err != nil { - return err - } - responses = append(responses, response) - return nil - }) - - if parseErr != nil { - return parseErr - } - - // Extract data from responses - for _, response := range responses { - if response.HasData() { - apps = append(apps, response.Data) - } - if response.IsMessage() { - messages = append(messages, response.Data.GetMessage()) - } - } - - // If we have error messages, return them - if len(messages) > 0 { - return &APIError{ - StatusCode: resp.StatusCode, - Messages: messages, - } - } - - // Set result based on type - switch v := result.(type) { - case *[]App: - *v = apps - default: - return fmt.Errorf("unsupported result type: %T", result) - } - - return nil -} - // getTransport creates an HTTP transport with current client settings func (c *Client) getTransport() *sdkhttp.Transport { return sdkhttp.NewTransport( diff --git a/sdk/edgeconnect/v2/types.go b/sdk/edgeconnect/v2/types.go index 0bb6875..7dea92e 100644 --- a/sdk/edgeconnect/v2/types.go +++ b/sdk/edgeconnect/v2/types.go @@ -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 @@ -291,7 +291,8 @@ type DeleteAppInstanceInput struct { // Response wraps a single API response type Response[T Message] struct { - Data T `json:"data"` + ResultResponse `json:",inline"` + Data T `json:"data"` } func (res *Response[T]) HasData() bool { @@ -326,6 +327,7 @@ func (r *ResultResponse) GetCode() int { type Responses[T Message] struct { Responses []Response[T] `json:"responses,omitempty"` StatusCode int `json:"-"` + Errors []error `json:"-"` } func (r *Responses[T]) GetData() []T { @@ -344,12 +346,15 @@ func (r *Responses[T]) GetMessages() []string { 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 r.StatusCode >= 200 && r.StatusCode < 400 + return len(r.Errors) == 0 && (r.StatusCode >= 200 && r.StatusCode < 400) } func (r *Responses[T]) Error() error { @@ -410,3 +415,7 @@ type CloudletResourceUsage struct { Region string `json:"region"` Usage map[string]interface{} `json:"usage"` } + +type ErrorMessage struct { + Message string +} From 02856be5412c8016b882bc63bd0daae1adf5c7f4 Mon Sep 17 00:00:00 2001 From: Patrick Sy Date: Mon, 17 Nov 2025 15:43:08 +0100 Subject: [PATCH 21/21] fix: Fixed error handling --- sdk/edgeconnect/v2/appinstance.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sdk/edgeconnect/v2/appinstance.go b/sdk/edgeconnect/v2/appinstance.go index eda3467..52dcf1f 100644 --- a/sdk/edgeconnect/v2/appinstance.go +++ b/sdk/edgeconnect/v2/appinstance.go @@ -285,8 +285,7 @@ func isV2Response(bodyBytes []byte) (bool, error) { func parseStreamingResponseV2[T Message](statusCode int, bodyBytes []byte) ([]T, error) { var result []T - // Try parsing as a direct JSON array first (v2 API format) - if err := json.Unmarshal(bodyBytes, &result); err == nil { + if err := json.Unmarshal(bodyBytes, &result); err != nil { return result, fmt.Errorf("failed to read response body: %w", err) }