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