diff --git a/.gitignore b/.gitignore index c08c1df..dec973c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist/ ### direnv ### .direnv .envrc + +edge-connect-client diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 4731016..248c94f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -10,11 +10,11 @@ builds: - CGO_ENABLED=0 goos: - linux - #- darwin - #- windows + - darwin + - windows goarch: - amd64 - #- arm64 + - arm64 archives: - formats: [tar.gz] @@ -43,9 +43,6 @@ signs: - "--detach-sign" - "${artifact}" -#binary_signs: -# - {} - changelog: abbrev: 10 filters: diff --git a/Makefile b/Makefile index 496876e..a8695c5 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ clean: # Lint the code lint: - golangci-lint run + go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2 run # Run all checks (generate, test, lint) check: test lint diff --git a/cmd/app.go b/cmd/app.go index 79fc2c5..37218bf 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -10,8 +10,8 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -37,7 +37,7 @@ func validateBaseURL(baseURL string) error { return fmt.Errorf("user and or password should not be set") } - if !(url.Path == "" || url.Path == "/") { + if url.Path != "" && url.Path != "/" { return fmt.Errorf("should not contain any path '%s'", url.Path) } @@ -291,12 +291,18 @@ func init() { cmd.Flags().StringVarP(&appName, "name", "n", "", "application name") cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version") cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)") - cmd.MarkFlagRequired("org") - cmd.MarkFlagRequired("region") + if err := cmd.MarkFlagRequired("org"); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired("region"); err != nil { + panic(err) + } } // Add required name flag for specific commands for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} { - cmd.MarkFlagRequired("name") + if err := cmd.MarkFlagRequired("name"); err != nil { + panic(err) + } } } diff --git a/cmd/apply.go b/cmd/apply.go index 1493841..cf2b37f 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -10,9 +10,9 @@ import ( "path/filepath" "strings" - applyv1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply/v1" - applyv2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply/v2" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + applyv1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/apply/v1" + applyv2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/apply/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" "github.com/spf13/cobra" ) @@ -31,7 +31,7 @@ the necessary changes to deploy your applications across multiple cloudlets.`, Run: func(cmd *cobra.Command, args []string) { if configFile == "" { fmt.Fprintf(os.Stderr, "Error: configuration file is required\n") - cmd.Usage() + _ = cmd.Usage() os.Exit(1) } @@ -208,20 +208,6 @@ func runApplyV2(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun return displayDeploymentResults(deployResult) } -type deploymentResult interface { - IsSuccess() bool - GetDuration() string - GetCompletedActions() []actionResult - GetFailedActions() []actionResult - GetError() error -} - -type actionResult interface { - GetType() string - GetTarget() string - GetError() error -} - func displayDeploymentResults(result interface{}) error { // Use reflection or type assertion to handle both v1 and v2 result types // For now, we'll use a simple approach that works with both @@ -288,7 +274,7 @@ func displayDeploymentResultsV2(deployResult *applyv2.ExecutionResult) error { func confirmDeployment() bool { fmt.Print("Do you want to proceed? (yes/no): ") var response string - fmt.Scanln(&response) + _, _ = fmt.Scanln(&response) switch response { case "yes", "y", "YES", "Y": @@ -305,5 +291,7 @@ func init() { applyCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without applying them") applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan") - applyCmd.MarkFlagRequired("file") + if err := applyCmd.MarkFlagRequired("file"); err != nil { + panic(err) + } } diff --git a/cmd/delete.go b/cmd/delete.go index 912741b..dcc1614 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -10,9 +10,9 @@ import ( "path/filepath" "strings" - deletev1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/delete/v1" - deletev2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/delete/v2" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + deletev1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/delete/v1" + deletev2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/delete/v2" "github.com/spf13/cobra" ) @@ -31,7 +31,7 @@ Instances are always deleted before the application.`, Run: func(cmd *cobra.Command, args []string) { if deleteConfigFile == "" { fmt.Fprintf(os.Stderr, "Error: configuration file is required\n") - cmd.Usage() + _ = cmd.Usage() os.Exit(1) } @@ -273,7 +273,7 @@ func displayDeletionResultsV2(deleteResult *deletev2.DeletionResult) error { func confirmDeletion() bool { fmt.Print("Do you want to proceed with deletion? (yes/no): ") var response string - fmt.Scanln(&response) + _, _ = fmt.Scanln(&response) switch response { case "yes", "y", "YES", "Y": @@ -290,5 +290,7 @@ func init() { deleteCmd.Flags().BoolVar(&deleteDryRun, "dry-run", false, "preview deletion without actually deleting resources") deleteCmd.Flags().BoolVar(&deleteAutoApprove, "auto-approve", false, "automatically approve the deletion plan") - deleteCmd.MarkFlagRequired("file") + if err := deleteCmd.MarkFlagRequired("file"); err != nil { + panic(err) + } } diff --git a/cmd/instance.go b/cmd/instance.go index 1eb6cb6..d856dea 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -5,8 +5,8 @@ import ( "fmt" "os" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" "github.com/spf13/cobra" ) @@ -15,6 +15,7 @@ var ( cloudletOrg string instanceName string flavorName string + appId string ) var appInstanceCmd = &cobra.Command{ @@ -104,7 +105,8 @@ var showInstanceCmd = &cobra.Command{ Name: cloudletName, }, } - instance, err := c.ShowAppInstance(context.Background(), instanceKey, region) + appkey := edgeconnect.AppKey{Name: appId} + instance, err := c.ShowAppInstance(context.Background(), instanceKey, appkey, region) if err != nil { fmt.Printf("Error showing app instance: %v\n", err) os.Exit(1) @@ -120,7 +122,8 @@ var showInstanceCmd = &cobra.Command{ Name: cloudletName, }, } - instance, err := c.ShowAppInstance(context.Background(), instanceKey, region) + appkey := v2.AppKey{Name: appId} + instance, err := c.ShowAppInstance(context.Background(), instanceKey, appkey, region) if err != nil { fmt.Printf("Error showing app instance: %v\n", err) os.Exit(1) @@ -146,7 +149,8 @@ var listInstancesCmd = &cobra.Command{ Name: cloudletName, }, } - instances, err := c.ShowAppInstances(context.Background(), instanceKey, region) + appKey := edgeconnect.AppKey{Name: appId} + instances, err := c.ShowAppInstances(context.Background(), instanceKey, appKey, region) if err != nil { fmt.Printf("Error listing app instances: %v\n", err) os.Exit(1) @@ -165,7 +169,8 @@ var listInstancesCmd = &cobra.Command{ Name: cloudletName, }, } - instances, err := c.ShowAppInstances(context.Background(), instanceKey, region) + appKey := v2.AppKey{Name: appId} + instances, err := c.ShowAppInstances(context.Background(), instanceKey, appKey, region) if err != nil { fmt.Printf("Error listing app instances: %v\n", err) os.Exit(1) @@ -229,18 +234,33 @@ 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") - cmd.MarkFlagRequired("org") - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("cloudlet") - cmd.MarkFlagRequired("cloudlet-org") - cmd.MarkFlagRequired("region") + if err := cmd.MarkFlagRequired("org"); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired("name"); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired("cloudlet"); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired("cloudlet-org"); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired("region"); err != nil { + panic(err) + } } // Add additional flags for create command createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)") createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version") createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)") - createInstanceCmd.MarkFlagRequired("app") - createInstanceCmd.MarkFlagRequired("flavor") + if err := createInstanceCmd.MarkFlagRequired("app"); err != nil { + panic(err) + } + if err := createInstanceCmd.MarkFlagRequired("flavor"); err != nil { + panic(err) + } } diff --git a/cmd/root.go b/cmd/root.go index dd22f72..52ae3ca 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,19 +44,35 @@ func init() { rootCmd.PersistentFlags().StringVar(&apiVersion, "api-version", "v2", "API version to use (v1 or v2)") rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging") - viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")) - viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")) - viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")) - viper.BindPFlag("api_version", rootCmd.PersistentFlags().Lookup("api-version")) + if err := viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")); err != nil { + panic(err) + } + if err := viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")); err != nil { + panic(err) + } + if err := viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")); err != nil { + panic(err) + } + if err := viper.BindPFlag("api_version", rootCmd.PersistentFlags().Lookup("api-version")); err != nil { + panic(err) + } } func initConfig() { viper.AutomaticEnv() viper.SetEnvPrefix("EDGE_CONNECT") - viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL") - viper.BindEnv("username", "EDGE_CONNECT_USERNAME") - viper.BindEnv("password", "EDGE_CONNECT_PASSWORD") - viper.BindEnv("api_version", "EDGE_CONNECT_API_VERSION") + if err := viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL"); err != nil { + panic(err) + } + if err := viper.BindEnv("username", "EDGE_CONNECT_USERNAME"); err != nil { + panic(err) + } + if err := viper.BindEnv("password", "EDGE_CONNECT_PASSWORD"); err != nil { + panic(err) + } + if err := viper.BindEnv("api_version", "EDGE_CONNECT_API_VERSION"); err != nil { + panic(err) + } if cfgFile != "" { viper.SetConfigFile(cfgFile) diff --git a/go.mod b/go.mod index dd77621..e88a974 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module edp.buildth.ing/DevFW-CICD/edge-connect-client +module edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 go 1.25.1 diff --git a/internal/apply/v1/manager.go b/internal/apply/v1/manager.go index a0668e8..048e85e 100644 --- a/internal/apply/v1/manager.go +++ b/internal/apply/v1/manager.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" ) // ResourceManagerInterface defines the interface for resource management diff --git a/internal/apply/v1/manager_test.go b/internal/apply/v1/manager_test.go index 9ed3cac..d4b4744 100644 --- a/internal/apply/v1/manager_test.go +++ b/internal/apply/v1/manager_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/internal/apply/v1/planner.go b/internal/apply/v1/planner.go index 33b8d9c..e1a1449 100644 --- a/internal/apply/v1/planner.go +++ b/internal/apply/v1/planner.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" ) // EdgeConnectClientInterface defines the methods needed for deployment planning @@ -21,7 +21,7 @@ type EdgeConnectClientInterface interface { CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error - ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) + ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error @@ -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,12 +323,7 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap // Extract outbound connections from the app current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections)) for i, conn := range app.RequiredOutboundConnections { - current.OutboundConnections[i] = SecurityRule{ - Protocol: conn.Protocol, - PortRangeMin: conn.PortRangeMin, - PortRangeMax: conn.PortRangeMax, - RemoteCIDR: conn.RemoteCIDR, - } + current.OutboundConnections[i] = SecurityRule(conn) } return current, nil @@ -347,8 +342,11 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire Name: desired.CloudletName, }, } + appKey := edgeconnect.AppKey{ + Name: desired.AppName, + } - instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region) + instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region) if err != nil { return nil, err } @@ -392,7 +390,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) @@ -470,7 +468,9 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string, if err != nil { return "", fmt.Errorf("failed to open manifest file: %w", err) } - defer file.Close() + defer func() { + _ = file.Close() + }() hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { @@ -505,18 +505,20 @@ func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) ti var duration time.Duration // App operations - if plan.AppAction.Type == ActionCreate { + switch plan.AppAction.Type { + case ActionCreate: duration += 30 * time.Second - } else if plan.AppAction.Type == ActionUpdate { + case ActionUpdate: duration += 15 * time.Second } // Instance operations (can be done in parallel) instanceDuration := time.Duration(0) for _, action := range plan.InstanceActions { - if action.Type == ActionCreate { + switch action.Type { + case ActionCreate: instanceDuration = max(instanceDuration, 2*time.Minute) - } else if action.Type == ActionUpdate { + case ActionUpdate: instanceDuration = max(instanceDuration, 1*time.Minute) } } diff --git a/internal/apply/v1/planner_test.go b/internal/apply/v1/planner_test.go index 8c1e48a..6530d8e 100644 --- a/internal/apply/v1/planner_test.go +++ b/internal/apply/v1/planner_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -29,7 +29,7 @@ func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect. return args.Get(0).(edgeconnect.App), args.Error(1) } -func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) { +func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error) { args := m.Called(ctx, instanceKey, region) if args.Get(0) == nil { return edgeconnect.AppInstance{}, args.Error(1) @@ -75,14 +75,6 @@ func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect return args.Get(0).([]edgeconnect.App), args.Error(1) } -func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) { - args := m.Called(ctx, instanceKey, region) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]edgeconnect.AppInstance), args.Error(1) -} - func TestNewPlanner(t *testing.T) { mockClient := &MockEdgeConnectClient{} planner := NewPlanner(mockClient) diff --git a/internal/apply/v1/strategy.go b/internal/apply/v1/strategy.go index 44f2471..db2f90f 100644 --- a/internal/apply/v1/strategy.go +++ b/internal/apply/v1/strategy.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" ) // DeploymentStrategy represents the type of deployment strategy diff --git a/internal/apply/v1/strategy_recreate.go b/internal/apply/v1/strategy_recreate.go index 1f6f121..b8cc736 100644 --- a/internal/apply/v1/strategy_recreate.go +++ b/internal/apply/v1/strategy_recreate.go @@ -10,8 +10,8 @@ import ( "sync" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" ) // RecreateStrategy implements the recreate deployment strategy diff --git a/internal/apply/v1/types.go b/internal/apply/v1/types.go index 223fa74..4863716 100644 --- a/internal/apply/v1/types.go +++ b/internal/apply/v1/types.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" ) // SecurityRule defines network access rules (alias to SDK type for consistency) diff --git a/internal/apply/v2/manager.go b/internal/apply/v2/manager.go index fc1b483..9bce91f 100644 --- a/internal/apply/v2/manager.go +++ b/internal/apply/v2/manager.go @@ -4,11 +4,13 @@ package v2 import ( "context" + "errors" "fmt" + "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ) // ResourceManagerInterface defines the interface for resource management @@ -204,7 +206,8 @@ func (rm *EdgeConnectResourceManager) RollbackDeployment(ctx context.Context, re rollbackErrors := []error{} - // Rollback completed instances (in reverse order) + // Phase 1: Delete resources that were created in this deployment attempt (in reverse order) + rm.logf("Phase 1: Rolling back created resources") for i := len(result.CompletedActions) - 1; i >= 0; i-- { action := result.CompletedActions[i] @@ -218,6 +221,32 @@ func (rm *EdgeConnectResourceManager) RollbackDeployment(ctx context.Context, re } } + // Phase 2: Restore resources that were deleted before the failed deployment + // This is critical for RecreateStrategy which deletes everything before recreating + if result.DeletedAppBackup != nil || len(result.DeletedInstancesBackup) > 0 { + rm.logf("Phase 2: Restoring deleted resources") + + // Restore app first (must exist before instances can be created) + if result.DeletedAppBackup != nil { + if err := rm.restoreApp(ctx, result.DeletedAppBackup); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to restore app: %w", err)) + rm.logf("Failed to restore app: %v", err) + } else { + rm.logf("Successfully restored app: %s", result.DeletedAppBackup.App.Key.Name) + } + } + + // Restore instances + for _, backup := range result.DeletedInstancesBackup { + if err := rm.restoreInstance(ctx, &backup); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to restore instance %s: %w", backup.Instance.Key.Name, err)) + rm.logf("Failed to restore instance %s: %v", backup.Instance.Key.Name, err) + } else { + rm.logf("Successfully restored instance: %s", backup.Instance.Key.Name) + } + } + } + if len(rollbackErrors) > 0 { return fmt.Errorf("rollback encountered %d errors: %v", len(rollbackErrors), rollbackErrors) } @@ -278,6 +307,125 @@ func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, acti return fmt.Errorf("instance action not found for rollback: %s", action.Target) } +// restoreApp recreates an app that was deleted during deployment +func (rm *EdgeConnectResourceManager) restoreApp(ctx context.Context, backup *AppBackup) error { + rm.logf("Restoring app: %s/%s version %s", + backup.App.Key.Organization, backup.App.Key.Name, backup.App.Key.Version) + + // Build a clean app input with only creation-safe fields + // We must exclude read-only fields like CreatedAt, UpdatedAt, etc. + appInput := &v2.NewAppInput{ + Region: backup.Region, + App: v2.App{ + Key: backup.App.Key, + Deployment: backup.App.Deployment, + ImageType: backup.App.ImageType, + ImagePath: backup.App.ImagePath, + AllowServerless: backup.App.AllowServerless, + DefaultFlavor: backup.App.DefaultFlavor, + ServerlessConfig: backup.App.ServerlessConfig, + DeploymentManifest: backup.App.DeploymentManifest, + DeploymentGenerator: backup.App.DeploymentGenerator, + RequiredOutboundConnections: backup.App.RequiredOutboundConnections, + // Explicitly omit read-only fields like CreatedAt, UpdatedAt, Fields, etc. + }, + } + + if err := rm.client.CreateApp(ctx, appInput); err != nil { + return fmt.Errorf("failed to restore app: %w", err) + } + + rm.logf("Successfully restored app: %s", backup.App.Key.Name) + return nil +} + +// restoreInstance recreates an instance that was deleted during deployment +func (rm *EdgeConnectResourceManager) restoreInstance(ctx context.Context, backup *InstanceBackup) error { + rm.logf("Restoring instance: %s on %s:%s", + backup.Instance.Key.Name, + backup.Instance.Key.CloudletKey.Organization, + backup.Instance.Key.CloudletKey.Name) + + // Build a clean instance input with only creation-safe fields + // We must exclude read-only fields like CloudletLoc, CreatedAt, etc. + instanceInput := &v2.NewAppInstanceInput{ + Region: backup.Region, + AppInst: v2.AppInstance{ + Key: backup.Instance.Key, + AppKey: backup.Instance.AppKey, + Flavor: backup.Instance.Flavor, + // Explicitly omit read-only fields like CloudletLoc, State, PowerState, CreatedAt, etc. + }, + } + + // Retry logic to handle namespace termination race conditions + maxRetries := 5 + retryDelay := 10 * time.Second + + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + rm.logf("Retrying instance restore %s (attempt %d/%d)", backup.Instance.Key.Name, attempt, maxRetries) + select { + case <-time.After(retryDelay): + case <-ctx.Done(): + return ctx.Err() + } + } + + err := rm.client.CreateAppInstance(ctx, instanceInput) + if err == nil { + rm.logf("Successfully restored instance: %s", backup.Instance.Key.Name) + return nil + } + + lastErr = err + + // Check if error is retryable + if !rm.isRetryableError(err) { + rm.logf("Failed to restore instance %s: %v (non-retryable error, giving up)", backup.Instance.Key.Name, err) + return fmt.Errorf("failed to restore instance: %w", err) + } + + if attempt < maxRetries { + rm.logf("Failed to restore instance %s: %v (will retry)", backup.Instance.Key.Name, err) + } + } + + return fmt.Errorf("failed to restore instance after %d attempts: %w", maxRetries+1, lastErr) +} + +// isRetryableError determines if an error should be retried +func (rm *EdgeConnectResourceManager) isRetryableError(err error) bool { + if err == nil { + return false + } + + errStr := strings.ToLower(err.Error()) + + // Special case: Kubernetes namespace termination race condition + // This is a transient 400 error that should be retried + if strings.Contains(errStr, "being terminated") || strings.Contains(errStr, "is being terminated") { + return true + } + + // Check if it's an APIError with a status code + var apiErr *v2.APIError + if errors.As(err, &apiErr) { + // Don't retry client errors (4xx) + if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { + return false + } + // Retry server errors (5xx) + if apiErr.StatusCode >= 500 { + return true + } + } + + // Retry all other errors (network issues, timeouts, etc.) + return true +} + // logf logs a message if a logger is configured func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) { if rm.logger != nil { diff --git a/internal/apply/v2/manager_test.go b/internal/apply/v2/manager_test.go index 68c60fd..6d5ef18 100644 --- a/internal/apply/v2/manager_test.go +++ b/internal/apply/v2/manager_test.go @@ -7,11 +7,12 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -464,6 +465,111 @@ func TestRollbackDeploymentFailure(t *testing.T) { mockClient.AssertExpectations(t) } +func TestRollbackDeploymentWithRestore(t *testing.T) { + mockClient := &MockResourceClient{} + logger := &TestLogger{} + manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) + + plan := createTestDeploymentPlan() + + // Simulate a RecreateStrategy scenario: + // 1. Old app and instance were deleted and backed up + // 2. New app was created successfully + // 3. New instance creation failed + // 4. Rollback should: delete new app, restore old app, restore old instance + oldApp := v2.App{ + Key: v2.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }, + Deployment: "kubernetes", + DeploymentManifest: "old-manifest-content", + } + + oldInstance := v2.AppInstance{ + Key: v2.AppInstanceKey{ + Organization: "test-org", + Name: "test-app-1.0.0-instance", + CloudletKey: v2.CloudletKey{ + Organization: "test-cloudlet-org", + Name: "test-cloudlet", + }, + }, + AppKey: v2.AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + }, + Flavor: v2.Flavor{Name: "small"}, + } + + result := &ExecutionResult{ + Plan: plan, + // Completed actions: new app was created before failure + CompletedActions: []ActionResult{ + { + Type: ActionCreate, + Target: "test-app", + Success: true, + }, + }, + // Failed action: new instance creation failed + FailedActions: []ActionResult{ + { + Type: ActionCreate, + Target: "test-app-1.0.0-instance", + Success: false, + }, + }, + // Backup of deleted resources + DeletedAppBackup: &AppBackup{ + App: oldApp, + Region: "US", + ManifestContent: "old-manifest-content", + }, + DeletedInstancesBackup: []InstanceBackup{ + { + Instance: oldInstance, + Region: "US", + }, + }, + } + + // Mock rollback operations in order: + // 1. Delete newly created app (rollback create) + mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). + Return(nil).Once() + + // 2. Restore old app (from backup) + mockClient.On("CreateApp", mock.Anything, mock.MatchedBy(func(input *v2.NewAppInput) bool { + return input.App.Key.Name == "test-app" && input.App.DeploymentManifest == "old-manifest-content" + })).Return(nil).Once() + + // 3. Restore old instance (from backup) + mockClient.On("CreateAppInstance", mock.Anything, mock.MatchedBy(func(input *v2.NewAppInstanceInput) bool { + return input.AppInst.Key.Name == "test-app-1.0.0-instance" + })).Return(nil).Once() + + ctx := context.Background() + err := manager.RollbackDeployment(ctx, result) + + require.NoError(t, err) + mockClient.AssertExpectations(t) + + // Verify rollback was logged + assert.Greater(t, len(logger.messages), 0) + // Should have messages about rolling back created resources and restoring deleted resources + hasRestoreLog := false + for _, msg := range logger.messages { + if strings.Contains(msg, "Restoring deleted resources") { + hasRestoreLog = true + break + } + } + assert.True(t, hasRestoreLog, "Should log restoration of deleted resources") +} + func TestConvertNetworkRules(t *testing.T) { network := &config.NetworkConfig{ OutboundConnections: []config.OutboundConnection{ diff --git a/internal/apply/v2/planner.go b/internal/apply/v2/planner.go index 52de1ee..797a411 100644 --- a/internal/apply/v2/planner.go +++ b/internal/apply/v2/planner.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ) // EdgeConnectClientInterface defines the methods needed for deployment planning @@ -21,7 +21,7 @@ type EdgeConnectClientInterface interface { CreateApp(ctx context.Context, input *v2.NewAppInput) error UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error - ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) (v2.AppInstance, error) + ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) (v2.AppInstance, error) CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error @@ -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,12 +323,7 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap // Extract outbound connections from the app current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections)) for i, conn := range app.RequiredOutboundConnections { - current.OutboundConnections[i] = SecurityRule{ - Protocol: conn.Protocol, - PortRangeMin: conn.PortRangeMin, - PortRangeMax: conn.PortRangeMax, - RemoteCIDR: conn.RemoteCIDR, - } + current.OutboundConnections[i] = SecurityRule(conn) } return current, nil @@ -348,7 +343,9 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire }, } - instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region) + appKey := v2.AppKey{Name: desired.AppName} + + instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region) if err != nil { return nil, err } @@ -392,7 +389,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) @@ -470,7 +467,9 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string, if err != nil { return "", fmt.Errorf("failed to open manifest file: %w", err) } - defer file.Close() + defer func() { + _ = file.Close() + }() hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { @@ -505,18 +504,20 @@ func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) ti var duration time.Duration // App operations - if plan.AppAction.Type == ActionCreate { + switch plan.AppAction.Type { + case ActionCreate: duration += 30 * time.Second - } else if plan.AppAction.Type == ActionUpdate { + case ActionUpdate: duration += 15 * time.Second } // Instance operations (can be done in parallel) instanceDuration := time.Duration(0) for _, action := range plan.InstanceActions { - if action.Type == ActionCreate { + switch action.Type { + case ActionCreate: instanceDuration = max(instanceDuration, 2*time.Minute) - } else if action.Type == ActionUpdate { + case ActionUpdate: instanceDuration = max(instanceDuration, 1*time.Minute) } } diff --git a/internal/apply/v2/planner_test.go b/internal/apply/v2/planner_test.go index fe56871..3fbdbc3 100644 --- a/internal/apply/v2/planner_test.go +++ b/internal/apply/v2/planner_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -29,7 +29,7 @@ func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, r return args.Get(0).(v2.App), args.Error(1) } -func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) (v2.AppInstance, error) { +func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) (v2.AppInstance, error) { args := m.Called(ctx, instanceKey, region) if args.Get(0) == nil { return v2.AppInstance{}, args.Error(1) diff --git a/internal/apply/v2/strategy.go b/internal/apply/v2/strategy.go index 6a1661a..78e3df4 100644 --- a/internal/apply/v2/strategy.go +++ b/internal/apply/v2/strategy.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" ) // DeploymentStrategy represents the type of deployment strategy diff --git a/internal/apply/v2/strategy_recreate.go b/internal/apply/v2/strategy_recreate.go index 739a454..6af0a68 100644 --- a/internal/apply/v2/strategy_recreate.go +++ b/internal/apply/v2/strategy_recreate.go @@ -10,8 +10,8 @@ import ( "sync" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ) // RecreateStrategy implements the recreate deployment strategy @@ -159,6 +159,19 @@ func (r *RecreateStrategy) deleteInstancesPhase(ctx context.Context, plan *Deplo return nil } + // Backup instances before deleting them (for rollback restoration) + r.logf("Backing up %d existing instances before deletion", len(instancesToDelete)) + for _, action := range instancesToDelete { + backup, err := r.backupInstance(ctx, action, config) + if err != nil { + r.logf("Warning: failed to backup instance %s before deletion: %v", action.InstanceName, err) + // Continue with deletion even if backup fails - this is best effort + } else { + result.DeletedInstancesBackup = append(result.DeletedInstancesBackup, *backup) + r.logf("Backed up instance: %s", action.InstanceName) + } + } + deleteResults := r.executeInstanceActionsWithRetry(ctx, instancesToDelete, "delete", config) for _, deleteResult := range deleteResults { @@ -172,6 +185,19 @@ func (r *RecreateStrategy) deleteInstancesPhase(ctx context.Context, plan *Deplo } r.logf("Phase 1 complete: deleted %d instances", len(deleteResults)) + + // Wait for Kubernetes namespace termination to complete + // This prevents "namespace is being terminated" errors when recreating instances + if len(deleteResults) > 0 { + waitTime := 5 * time.Second + r.logf("Waiting %v for namespace termination to complete...", waitTime) + select { + case <-time.After(waitTime): + case <-ctx.Done(): + return ctx.Err() + } + } + return nil } @@ -184,6 +210,17 @@ func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentP r.logf("Phase 2: Deleting existing application") + // Backup app before deleting it (for rollback restoration) + r.logf("Backing up existing app before deletion") + backup, err := r.backupApp(ctx, plan, config) + if err != nil { + r.logf("Warning: failed to backup app before deletion: %v", err) + // Continue with deletion even if backup fails - this is best effort + } else { + result.DeletedAppBackup = backup + r.logf("Backed up app: %s", plan.AppAction.Desired.Name) + } + appKey := v2.AppKey{ Organization: plan.AppAction.Desired.Organization, Name: plan.AppAction.Desired.Name, @@ -516,6 +553,54 @@ func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppActi return true, nil } +// backupApp fetches and stores the current app state before deletion +func (r *RecreateStrategy) backupApp(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig) (*AppBackup, error) { + appKey := v2.AppKey{ + Organization: plan.AppAction.Desired.Organization, + Name: plan.AppAction.Desired.Name, + Version: plan.AppAction.Desired.Version, + } + + app, err := r.client.ShowApp(ctx, appKey, plan.AppAction.Desired.Region) + if err != nil { + return nil, fmt.Errorf("failed to fetch app for backup: %w", err) + } + + backup := &AppBackup{ + App: app, + Region: plan.AppAction.Desired.Region, + ManifestContent: app.DeploymentManifest, + } + + return backup, nil +} + +// backupInstance fetches and stores the current instance state before deletion +func (r *RecreateStrategy) backupInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (*InstanceBackup, error) { + instanceKey := v2.AppInstanceKey{ + Organization: action.Desired.Organization, + Name: action.InstanceName, + CloudletKey: v2.CloudletKey{ + Organization: action.Target.CloudletOrg, + Name: action.Target.CloudletName, + }, + } + + 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 { @@ -530,6 +615,14 @@ func isRetryableError(err error) bool { return false } + errStr := strings.ToLower(err.Error()) + + // Special case: Kubernetes namespace termination race condition + // This is a transient 400 error that should be retried + if strings.Contains(errStr, "being terminated") || strings.Contains(errStr, "is being terminated") { + return true + } + // Check if it's an APIError with a status code var apiErr *v2.APIError if errors.As(err, &apiErr) { diff --git a/internal/apply/v2/types.go b/internal/apply/v2/types.go index 90b7956..26d998e 100644 --- a/internal/apply/v2/types.go +++ b/internal/apply/v2/types.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ) // SecurityRule defines network access rules (alias to SDK type for consistency) @@ -271,6 +271,12 @@ type ExecutionResult struct { // RollbackSuccess indicates if rollback was successful RollbackSuccess bool + + // DeletedAppBackup stores the app that was deleted (for rollback restoration) + DeletedAppBackup *AppBackup + + // DeletedInstancesBackup stores instances that were deleted (for rollback restoration) + DeletedInstancesBackup []InstanceBackup } // ActionResult represents the result of executing a single action @@ -294,6 +300,27 @@ type ActionResult struct { Details string } +// AppBackup stores a deleted app's complete state for rollback restoration +type AppBackup struct { + // App is the full app object that was deleted + App v2.App + + // Region where the app was deployed + Region string + + // ManifestContent is the deployment manifest content + ManifestContent string +} + +// InstanceBackup stores a deleted instance's complete state for rollback restoration +type InstanceBackup struct { + // Instance is the full instance object that was deleted + Instance v2.AppInstance + + // Region where the instance was deployed + Region string +} + // IsEmpty returns true if the deployment plan has no actions to perform func (dp *DeploymentPlan) IsEmpty() bool { if dp.AppAction.Type != ActionNone { diff --git a/internal/config/example_test.go b/internal/config/example_test.go index 536399f..f7299c2 100644 --- a/internal/config/example_test.go +++ b/internal/config/example_test.go @@ -70,13 +70,13 @@ func TestValidateExampleStructure(t *testing.T) { config := &EdgeConnectConfig{ Kind: "edgeconnect-deployment", Metadata: Metadata{ - Name: "edge-app-demo", - AppVersion: "1.0.0", + Name: "edge-app-demo", + AppVersion: "1.0.0", Organization: "edp2", }, Spec: Spec{ DockerApp: &DockerApp{ // Use DockerApp to avoid manifest file validation - Image: "nginx:latest", + Image: "nginx:latest", }, InfraTemplate: []InfraTemplate{ { diff --git a/internal/delete/v1/manager.go b/internal/delete/v1/manager.go index 470ac37..e20eba9 100644 --- a/internal/delete/v1/manager.go +++ b/internal/delete/v1/manager.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" ) // ResourceManagerInterface defines the interface for resource management diff --git a/internal/delete/v1/planner.go b/internal/delete/v1/planner.go index d436057..ca97b84 100644 --- a/internal/delete/v1/planner.go +++ b/internal/delete/v1/planner.go @@ -7,14 +7,14 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" ) // EdgeConnectClientInterface defines the methods needed for deletion planning type EdgeConnectClientInterface interface { ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) - ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) + ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) ([]edgeconnect.AppInstance, error) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error } @@ -154,8 +154,9 @@ func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config * Name: infra.CloudletName, }, } + appKey := edgeconnect.AppKey{Name: config.Metadata.Name} - instances, err := p.client.ShowAppInstances(ctx, instanceKey, infra.Region) + instances, err := p.client.ShowAppInstances(ctx, instanceKey, appKey, infra.Region) if err != nil { // If it's a not found error, just continue if isNotFoundError(err) { diff --git a/internal/delete/v2/manager.go b/internal/delete/v2/manager.go index a644f32..35518a2 100644 --- a/internal/delete/v2/manager.go +++ b/internal/delete/v2/manager.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ) // ResourceManagerInterface defines the interface for resource management diff --git a/internal/delete/v2/manager_test.go b/internal/delete/v2/manager_test.go index fd098af..d021f20 100644 --- a/internal/delete/v2/manager_test.go +++ b/internal/delete/v2/manager_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -27,7 +27,7 @@ func (m *MockResourceClient) ShowApp(ctx context.Context, appKey v2.AppKey, regi return args.Get(0).(v2.App), args.Error(1) } -func (m *MockResourceClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) { +func (m *MockResourceClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) { args := m.Called(ctx, instanceKey, region) if args.Get(0) == nil { return nil, args.Error(1) diff --git a/internal/delete/v2/planner.go b/internal/delete/v2/planner.go index e77cd9e..76ec1c6 100644 --- a/internal/delete/v2/planner.go +++ b/internal/delete/v2/planner.go @@ -7,14 +7,14 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ) // EdgeConnectClientInterface defines the methods needed for deletion planning type EdgeConnectClientInterface interface { ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) - ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) + ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error } @@ -154,8 +154,9 @@ func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config * Name: infra.CloudletName, }, } + appKey := v2.AppKey{Name: config.Metadata.Name} - instances, err := p.client.ShowAppInstances(ctx, instanceKey, infra.Region) + instances, err := p.client.ShowAppInstances(ctx, instanceKey, appKey, infra.Region) if err != nil { // If it's a not found error, just continue if isNotFoundError(err) { diff --git a/internal/delete/v2/planner_test.go b/internal/delete/v2/planner_test.go index c37a318..292cecc 100644 --- a/internal/delete/v2/planner_test.go +++ b/internal/delete/v2/planner_test.go @@ -8,8 +8,8 @@ import ( "path/filepath" "testing" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -28,7 +28,7 @@ func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, r return args.Get(0).(v2.App), args.Error(1) } -func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) { +func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) { args := m.Called(ctx, instanceKey, region) if args.Get(0) == nil { return nil, args.Error(1) diff --git a/internal/delete/v2/types_test.go b/internal/delete/v2/types_test.go index 8dfa6b0..225c5ef 100644 --- a/internal/delete/v2/types_test.go +++ b/internal/delete/v2/types_test.go @@ -16,8 +16,8 @@ func TestDeletionPlan_IsEmpty(t *testing.T) { { name: "empty plan with no resources", plan: &DeletionPlan{ - ConfigName: "test-config", - AppToDelete: nil, + ConfigName: "test-config", + AppToDelete: nil, InstancesToDelete: []InstanceDeletion{}, }, expected: true, diff --git a/main.go b/main.go index 9bc902d..2d198e9 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,6 @@ package main -import "edp.buildth.ing/DevFW-CICD/edge-connect-client/cmd" +import "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/cmd" func main() { cmd.Execute() diff --git a/public.gpg b/public.gpg new file mode 100644 index 0000000..32d15f1 Binary files /dev/null and b/public.gpg differ diff --git a/sdk/README.md b/sdk/README.md index 89dc673..be2374f 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -16,7 +16,7 @@ A comprehensive Go SDK for the EdgeXR Master Controller API, providing typed int ### Installation ```go -import v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" +import v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ``` ### Authentication diff --git a/sdk/edgeconnect/appinstance.go b/sdk/edgeconnect/appinstance.go index a26f45c..34e3486 100644 --- a/sdk/edgeconnect/appinstance.go +++ b/sdk/edgeconnect/appinstance.go @@ -9,7 +9,7 @@ import ( "fmt" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http" ) // CreateAppInstance creates a new application instance in the specified region @@ -23,7 +23,9 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp if err != nil { return fmt.Errorf("CreateAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "CreateAppInstance") @@ -43,12 +45,12 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp // ShowAppInstance retrieves a single application instance by key and region // Maps to POST /auth/ctrl/ShowAppInst -func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) (AppInstance, error) { +func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) (AppInstance, error) { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: appInstKey}, + AppInstance: AppInstance{AppKey: appKey, Key: appInstKey}, Region: region, } @@ -56,7 +58,9 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, if err != nil { return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return AppInstance{}, fmt.Errorf("app instance %s/%s: %w", @@ -83,12 +87,12 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, // ShowAppInstances retrieves all application instances matching the filter criteria // Maps to POST /auth/ctrl/ShowAppInst -func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, region string) ([]AppInstance, error) { +func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) ([]AppInstance, error) { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: appInstKey}, + AppInstance: AppInstance{Key: appInstKey, AppKey: appKey}, Region: region, } @@ -96,7 +100,9 @@ func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey if err != nil { return nil, fmt.Errorf("ShowAppInstances failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { return nil, c.handleErrorResponse(resp, "ShowAppInstances") @@ -125,7 +131,9 @@ func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstance if err != nil { return fmt.Errorf("UpdateAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "UpdateAppInstance") @@ -152,7 +160,9 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK if err != nil { return fmt.Errorf("RefreshAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "RefreshAppInstance") @@ -179,7 +189,9 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe if err != nil { return fmt.Errorf("DeleteAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // 404 is acceptable for delete operations (already deleted) if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { @@ -201,6 +213,10 @@ func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result i var errorMessage string parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { + // On permission denied, Edge API returns just an empty array []! + if len(line) == 0 || line[0] == '[' { + return fmt.Errorf("%w", ErrFaultyResponsePerhaps403) + } // Try parsing as ResultResponse first (error format) var resultResp ResultResponse if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" { diff --git a/sdk/edgeconnect/appinstance_test.go b/sdk/edgeconnect/appinstance_test.go index ac9c1eb..3545904 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,6 +156,7 @@ func TestCreateAppInstance(t *testing.T) { func TestShowAppInstance(t *testing.T) { tests := []struct { name string + appKey AppKey appInstKey AppInstanceKey region string mockStatusCode int @@ -173,6 +174,7 @@ func TestShowAppInstance(t *testing.T) { Name: "testcloudlet", }, }, + appKey: AppKey{Name: "test-app-id"}, region: "us-west", mockStatusCode: 200, mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}} @@ -190,6 +192,7 @@ func TestShowAppInstance(t *testing.T) { Name: "testcloudlet", }, }, + appKey: AppKey{Name: "test-app-id"}, region: "us-west", mockStatusCode: 404, mockResponse: "", @@ -207,7 +210,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() @@ -219,7 +222,7 @@ func TestShowAppInstance(t *testing.T) { // Execute test ctx := context.Background() - appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region) + appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region) // Verify results if tt.expectError { @@ -254,14 +257,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"}, "us-west") + appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west") require.NoError(t, err) assert.Len(t, appInstances, 2) @@ -361,7 +364,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 70f5dea..a086475 100644 --- a/sdk/edgeconnect/apps.go +++ b/sdk/edgeconnect/apps.go @@ -10,12 +10,13 @@ import ( "io" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http" ) var ( // ErrResourceNotFound indicates the requested resource was not found - ErrResourceNotFound = fmt.Errorf("resource not found") + ErrResourceNotFound = fmt.Errorf("resource not found") + ErrFaultyResponsePerhaps403 = fmt.Errorf("faulty response from API, may indicate permission denied") ) // CreateApp creates a new application in the specified region @@ -28,7 +29,9 @@ func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error { if err != nil { return fmt.Errorf("CreateApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "CreateApp") @@ -55,7 +58,9 @@ func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App if err != nil { return App{}, fmt.Errorf("ShowApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", @@ -95,7 +100,9 @@ func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([] if err != nil { return nil, fmt.Errorf("ShowApps failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { return nil, c.handleErrorResponse(resp, "ShowApps") @@ -124,7 +131,9 @@ func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error { if err != nil { return fmt.Errorf("UpdateApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "UpdateApp") @@ -151,7 +160,9 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er if err != nil { return fmt.Errorf("DeleteApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // 404 is acceptable for delete operations (already deleted) if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { @@ -169,6 +180,10 @@ func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) var responses []Response[App] parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { + // On permission denied, Edge API returns just an empty array []! + if len(line) == 0 || line[0] == '[' { + return fmt.Errorf("%w", ErrFaultyResponsePerhaps403) + } var response Response[App] if err := json.Unmarshal(line, &response); err != nil { return err @@ -238,7 +253,9 @@ func (c *Client) handleErrorResponse(resp *http.Response, operation string) erro bodyBytes := []byte{} if resp.Body != nil { - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() bodyBytes, _ = io.ReadAll(resp.Body) messages = append(messages, string(bodyBytes)) } diff --git a/sdk/edgeconnect/apps_test.go b/sdk/edgeconnect/apps_test.go index 30531f6..88437ca 100644 --- a/sdk/edgeconnect/apps_test.go +++ b/sdk/edgeconnect/apps_test.go @@ -67,7 +67,7 @@ func TestCreateApp(t *testing.T) { assert.Equal(t, "application/json", r.Header.Get("Content-Type")) w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) })) defer server.Close() @@ -139,7 +139,7 @@ func TestShowApp(t *testing.T) { w.WriteHeader(tt.mockStatusCode) if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) } })) defer server.Close() @@ -186,7 +186,7 @@ func TestShowApps(t *testing.T) { {"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}} ` w.WriteHeader(200) - w.Write([]byte(response)) + _, _ = w.Write([]byte(response)) })) defer server.Close() @@ -277,7 +277,7 @@ func TestUpdateApp(t *testing.T) { assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization) w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) })) defer server.Close() @@ -407,13 +407,3 @@ func TestAPIError(t *testing.T) { assert.Equal(t, 400, err.StatusCode) assert.Len(t, err.Messages, 2) } - -// Helper function to create a test server that handles streaming JSON responses -func createStreamingJSONServer(responses []string, statusCode int) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(statusCode) - for _, response := range responses { - w.Write([]byte(response + "\n")) - } - })) -} diff --git a/sdk/edgeconnect/auth.go b/sdk/edgeconnect/auth.go index eab24b9..cf6067b 100644 --- a/sdk/edgeconnect/auth.go +++ b/sdk/edgeconnect/auth.go @@ -138,7 +138,9 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e if err != nil { return "", err } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // Read response body - same as existing implementation body, err := io.ReadAll(resp.Body) diff --git a/sdk/edgeconnect/auth_test.go b/sdk/edgeconnect/auth_test.go index 8ea3176..8e68dc4 100644 --- a/sdk/edgeconnect/auth_test.go +++ b/sdk/edgeconnect/auth_test.go @@ -56,7 +56,7 @@ func TestUsernamePasswordProvider_Success(t *testing.T) { // Return token response := map[string]string{"token": "dynamic-token-456"} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) })) defer loginServer.Close() @@ -75,7 +75,7 @@ func TestUsernamePasswordProvider_LoginFailure(t *testing.T) { // Mock login server that returns error loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("Invalid credentials")) + _, _ = w.Write([]byte("Invalid credentials")) })) defer loginServer.Close() @@ -99,7 +99,7 @@ func TestUsernamePasswordProvider_TokenCaching(t *testing.T) { callCount++ response := map[string]string{"token": "cached-token-789"} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) })) defer loginServer.Close() @@ -128,7 +128,7 @@ func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) { callCount++ response := map[string]string{"token": "refreshed-token-999"} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) })) defer loginServer.Close() @@ -157,7 +157,7 @@ func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) { callCount++ response := map[string]string{"token": "new-token-after-invalidation"} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) })) defer loginServer.Close() @@ -185,7 +185,7 @@ func TestUsernamePasswordProvider_BadJSONResponse(t *testing.T) { // Mock server returning invalid JSON loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Write([]byte("invalid json response")) + _, _ = w.Write([]byte("invalid json response")) })) defer loginServer.Close() diff --git a/sdk/edgeconnect/cloudlet.go b/sdk/edgeconnect/cloudlet.go index e3f4b7d..142b9d6 100644 --- a/sdk/edgeconnect/cloudlet.go +++ b/sdk/edgeconnect/cloudlet.go @@ -9,7 +9,7 @@ import ( "fmt" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http" ) // CreateCloudlet creates a new cloudlet in the specified region @@ -22,7 +22,9 @@ func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) er if err != nil { return fmt.Errorf("CreateCloudlet failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "CreateCloudlet") @@ -49,7 +51,9 @@ func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, regi if err != nil { return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", @@ -89,7 +93,9 @@ func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, reg if err != nil { return nil, fmt.Errorf("ShowCloudlets failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { return nil, c.handleErrorResponse(resp, "ShowCloudlets") @@ -123,7 +129,9 @@ func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, re if err != nil { return fmt.Errorf("DeleteCloudlet failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // 404 is acceptable for delete operations (already deleted) if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { @@ -151,7 +159,9 @@ func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKe if err != nil { return nil, fmt.Errorf("GetCloudletManifest failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w", @@ -189,7 +199,9 @@ func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey Cloud if err != nil { return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w", diff --git a/sdk/edgeconnect/cloudlet_test.go b/sdk/edgeconnect/cloudlet_test.go index 7d129bb..b029f17 100644 --- a/sdk/edgeconnect/cloudlet_test.go +++ b/sdk/edgeconnect/cloudlet_test.go @@ -70,7 +70,7 @@ func TestCreateCloudlet(t *testing.T) { assert.Equal(t, "application/json", r.Header.Get("Content-Type")) w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) })) defer server.Close() @@ -140,7 +140,7 @@ func TestShowCloudlet(t *testing.T) { w.WriteHeader(tt.mockStatusCode) if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) } })) defer server.Close() @@ -187,7 +187,7 @@ func TestShowCloudlets(t *testing.T) { {"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}} ` w.WriteHeader(200) - w.Write([]byte(response)) + _, _ = w.Write([]byte(response)) })) defer server.Close() @@ -312,7 +312,7 @@ func TestGetCloudletManifest(t *testing.T) { w.WriteHeader(tt.mockStatusCode) if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) } })) defer server.Close() @@ -380,7 +380,7 @@ func TestGetCloudletResourceUsage(t *testing.T) { w.WriteHeader(tt.mockStatusCode) if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) } })) defer server.Close() diff --git a/sdk/edgeconnect/types.go b/sdk/edgeconnect/types.go index 7fd39fc..307ed52 100644 --- a/sdk/edgeconnect/types.go +++ b/sdk/edgeconnect/types.go @@ -60,74 +60,74 @@ const ( // AppInstance field constants for partial updates (based on EdgeXR API specification) const ( - AppInstFieldKey = "2" - AppInstFieldKeyAppKey = "2.1" - AppInstFieldKeyAppKeyOrganization = "2.1.1" - AppInstFieldKeyAppKeyName = "2.1.2" - AppInstFieldKeyAppKeyVersion = "2.1.3" - AppInstFieldKeyClusterInstKey = "2.4" - AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1" - AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1" - AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2" - AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1" - AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2" + AppInstFieldKey = "2" + AppInstFieldKeyAppKey = "2.1" + AppInstFieldKeyAppKeyOrganization = "2.1.1" + AppInstFieldKeyAppKeyName = "2.1.2" + AppInstFieldKeyAppKeyVersion = "2.1.3" + AppInstFieldKeyClusterInstKey = "2.4" + AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1" + AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1" + AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2" + AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1" + AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2" AppInstFieldKeyClusterInstKeyCloudletKeyFederatedOrganization = "2.4.2.3" - AppInstFieldKeyClusterInstKeyOrganization = "2.4.3" - AppInstFieldCloudletLoc = "3" - AppInstFieldCloudletLocLatitude = "3.1" - AppInstFieldCloudletLocLongitude = "3.2" - AppInstFieldCloudletLocHorizontalAccuracy = "3.3" - AppInstFieldCloudletLocVerticalAccuracy = "3.4" - AppInstFieldCloudletLocAltitude = "3.5" - AppInstFieldCloudletLocCourse = "3.6" - AppInstFieldCloudletLocSpeed = "3.7" - AppInstFieldCloudletLocTimestamp = "3.8" - AppInstFieldCloudletLocTimestampSeconds = "3.8.1" - AppInstFieldCloudletLocTimestampNanos = "3.8.2" - AppInstFieldUri = "4" - AppInstFieldLiveness = "6" - AppInstFieldMappedPorts = "9" - AppInstFieldMappedPortsProto = "9.1" - AppInstFieldMappedPortsInternalPort = "9.2" - AppInstFieldMappedPortsPublicPort = "9.3" - AppInstFieldMappedPortsFqdnPrefix = "9.5" - AppInstFieldMappedPortsEndPort = "9.6" - AppInstFieldMappedPortsTls = "9.7" - AppInstFieldMappedPortsNginx = "9.8" - AppInstFieldMappedPortsMaxPktSize = "9.9" - AppInstFieldFlavor = "12" - AppInstFieldFlavorName = "12.1" - AppInstFieldState = "14" - AppInstFieldErrors = "15" - AppInstFieldCrmOverride = "16" - AppInstFieldRuntimeInfo = "17" - AppInstFieldRuntimeInfoContainerIds = "17.1" - AppInstFieldCreatedAt = "21" - AppInstFieldCreatedAtSeconds = "21.1" - AppInstFieldCreatedAtNanos = "21.2" - AppInstFieldAutoClusterIpAccess = "22" - AppInstFieldRevision = "24" - AppInstFieldForceUpdate = "25" - AppInstFieldUpdateMultiple = "26" - AppInstFieldConfigs = "27" - AppInstFieldConfigsKind = "27.1" - AppInstFieldConfigsConfig = "27.2" - AppInstFieldHealthCheck = "29" - AppInstFieldPowerState = "31" - AppInstFieldExternalVolumeSize = "32" - AppInstFieldAvailabilityZone = "33" - AppInstFieldVmFlavor = "34" - AppInstFieldOptRes = "35" - AppInstFieldUpdatedAt = "36" - AppInstFieldUpdatedAtSeconds = "36.1" - AppInstFieldUpdatedAtNanos = "36.2" - AppInstFieldRealClusterName = "37" - AppInstFieldInternalPortToLbIp = "38" - AppInstFieldInternalPortToLbIpKey = "38.1" - AppInstFieldInternalPortToLbIpValue = "38.2" - AppInstFieldDedicatedIp = "39" - AppInstFieldUniqueId = "40" - AppInstFieldDnsLabel = "41" + AppInstFieldKeyClusterInstKeyOrganization = "2.4.3" + AppInstFieldCloudletLoc = "3" + AppInstFieldCloudletLocLatitude = "3.1" + AppInstFieldCloudletLocLongitude = "3.2" + AppInstFieldCloudletLocHorizontalAccuracy = "3.3" + AppInstFieldCloudletLocVerticalAccuracy = "3.4" + AppInstFieldCloudletLocAltitude = "3.5" + AppInstFieldCloudletLocCourse = "3.6" + AppInstFieldCloudletLocSpeed = "3.7" + AppInstFieldCloudletLocTimestamp = "3.8" + AppInstFieldCloudletLocTimestampSeconds = "3.8.1" + AppInstFieldCloudletLocTimestampNanos = "3.8.2" + AppInstFieldUri = "4" + AppInstFieldLiveness = "6" + AppInstFieldMappedPorts = "9" + AppInstFieldMappedPortsProto = "9.1" + AppInstFieldMappedPortsInternalPort = "9.2" + AppInstFieldMappedPortsPublicPort = "9.3" + AppInstFieldMappedPortsFqdnPrefix = "9.5" + AppInstFieldMappedPortsEndPort = "9.6" + AppInstFieldMappedPortsTls = "9.7" + AppInstFieldMappedPortsNginx = "9.8" + AppInstFieldMappedPortsMaxPktSize = "9.9" + AppInstFieldFlavor = "12" + AppInstFieldFlavorName = "12.1" + AppInstFieldState = "14" + AppInstFieldErrors = "15" + AppInstFieldCrmOverride = "16" + AppInstFieldRuntimeInfo = "17" + AppInstFieldRuntimeInfoContainerIds = "17.1" + AppInstFieldCreatedAt = "21" + AppInstFieldCreatedAtSeconds = "21.1" + AppInstFieldCreatedAtNanos = "21.2" + AppInstFieldAutoClusterIpAccess = "22" + AppInstFieldRevision = "24" + AppInstFieldForceUpdate = "25" + AppInstFieldUpdateMultiple = "26" + AppInstFieldConfigs = "27" + AppInstFieldConfigsKind = "27.1" + AppInstFieldConfigsConfig = "27.2" + AppInstFieldHealthCheck = "29" + AppInstFieldPowerState = "31" + AppInstFieldExternalVolumeSize = "32" + AppInstFieldAvailabilityZone = "33" + AppInstFieldVmFlavor = "34" + AppInstFieldOptRes = "35" + AppInstFieldUpdatedAt = "36" + AppInstFieldUpdatedAtSeconds = "36.1" + AppInstFieldUpdatedAtNanos = "36.2" + AppInstFieldRealClusterName = "37" + AppInstFieldInternalPortToLbIp = "38" + AppInstFieldInternalPortToLbIpKey = "38.1" + AppInstFieldInternalPortToLbIpValue = "38.2" + AppInstFieldDedicatedIp = "39" + AppInstFieldUniqueId = "40" + AppInstFieldDnsLabel = "41" ) // Message interface for types that can provide error messages diff --git a/sdk/edgeconnect/v2/appinstance.go b/sdk/edgeconnect/v2/appinstance.go index 4fb7204..52dcf1f 100644 --- a/sdk/edgeconnect/v2/appinstance.go +++ b/sdk/edgeconnect/v2/appinstance.go @@ -10,8 +10,7 @@ import ( "fmt" "io" "net/http" - - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + "strings" ) // CreateAppInstance creates a new application instance in the specified region @@ -25,15 +24,16 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp if err != nil { return fmt.Errorf("CreateAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "CreateAppInstance") } // Parse streaming JSON response - var appInstances []AppInstance - if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + if _, err = parseStreamingResponse[AppInstance](resp); err != nil { return fmt.Errorf("ShowAppInstance failed to parse response: %w", err) } @@ -45,7 +45,7 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp // ShowAppInstance retrieves a single application instance by key and region // Maps to POST /auth/ctrl/ShowAppInst -func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) (AppInstance, error) { +func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) (AppInstance, error) { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" @@ -58,7 +58,9 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, if err != nil { return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return AppInstance{}, fmt.Errorf("app instance %s/%s: %w", @@ -71,7 +73,7 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, // Parse streaming JSON response var appInstances []AppInstance - if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + if appInstances, err = parseStreamingResponse[AppInstance](resp); err != nil { return AppInstance{}, fmt.Errorf("ShowAppInstance failed to parse response: %w", err) } @@ -85,12 +87,12 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, // ShowAppInstances retrieves all application instances matching the filter criteria // Maps to POST /auth/ctrl/ShowAppInst -func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, region string) ([]AppInstance, error) { +func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) ([]AppInstance, error) { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: appInstKey}, + AppInstance: AppInstance{Key: appInstKey, AppKey: appKey}, Region: region, } @@ -98,18 +100,20 @@ func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey if err != nil { return nil, fmt.Errorf("ShowAppInstances failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { return nil, c.handleErrorResponse(resp, "ShowAppInstances") } - var appInstances []AppInstance if resp.StatusCode == http.StatusNotFound { - return appInstances, nil // Return empty slice for not found + return []AppInstance{}, nil // Return empty slice for not found } - if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + var appInstances []AppInstance + if appInstances, err = parseStreamingResponse[AppInstance](resp); err != nil { return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err) } @@ -127,7 +131,9 @@ func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstance if err != nil { return fmt.Errorf("UpdateAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "UpdateAppInstance") @@ -154,7 +160,9 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK if err != nil { return fmt.Errorf("RefreshAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "RefreshAppInstance") @@ -181,7 +189,9 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe if err != nil { return fmt.Errorf("DeleteAppInstance failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // 404 is acceptable for delete operations (already deleted) if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { @@ -195,88 +205,89 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe } // parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances -func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error { +func parseStreamingResponse[T Message](resp *http.Response) ([]T, error) { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("failed to read response body: %w", err) + return []T{}, fmt.Errorf("failed to read response body: %w", err) } - // Try parsing as a direct JSON array first (v2 API format) - switch v := result.(type) { - case *[]AppInstance: - var appInstances []AppInstance - if err := json.Unmarshal(bodyBytes, &appInstances); err == nil { - *v = appInstances - return nil - } + // todo finish check the responses, test them, and make a unify result, probably need + // to update the response parameter to the message type e.g. App or AppInst + isV2, err := isV2Response(bodyBytes) + if err != nil { + return []T{}, fmt.Errorf("failed to parse streaming response: %w", err) } - // Fall back to streaming format (v1 API format) - var appInstances []AppInstance - var messages []string - var hasError bool - var errorCode int - var errorMessage string - - parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { - // Try parsing as ResultResponse first (error format) - var resultResp ResultResponse - if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" { - if resultResp.IsError() { - hasError = true - errorCode = resultResp.GetCode() - errorMessage = resultResp.GetMessage() - } - return nil + if isV2 { + resultV2, err := parseStreamingResponseV2[T](resp.StatusCode, bodyBytes) + if err != nil { + return []T{}, err } - - // Try parsing as Response[AppInstance] - var response Response[AppInstance] - if err := json.Unmarshal(line, &response); err != nil { - return err - } - - if response.HasData() { - appInstances = append(appInstances, response.Data) - } - if response.IsMessage() { - msg := response.Data.GetMessage() - messages = append(messages, msg) - // Check for error indicators in messages - if msg == "CreateError" || msg == "UpdateError" || msg == "DeleteError" { - hasError = true - } - } - return nil - }) - - if parseErr != nil { - return parseErr + return resultV2, nil } - // If we detected an error, return it - if hasError { - apiErr := &APIError{ - StatusCode: resp.StatusCode, - Messages: messages, - } - if errorCode > 0 { - apiErr.StatusCode = errorCode - apiErr.Code = fmt.Sprintf("%d", errorCode) - } - if errorMessage != "" { - apiErr.Messages = append([]string{errorMessage}, apiErr.Messages...) - } - return apiErr + resultV1, err := parseStreamingResponseV1[T](resp.StatusCode, bodyBytes) + if err != nil { + return nil, err } - // Set result based on type - switch v := result.(type) { - case *[]AppInstance: - *v = appInstances - default: - return fmt.Errorf("unsupported result type: %T", result) + if !resultV1.IsSuccessful() { + return []T{}, resultV1.Error() } - return nil + return resultV1.GetData(), nil +} + +func parseStreamingResponseV1[T Message](statusCode int, bodyBytes []byte) (Responses[T], error) { + // Fall back to streaming format (v1 API format) + var responses Responses[T] + responses.StatusCode = statusCode + + decoder := json.NewDecoder(bytes.NewReader(bodyBytes)) + for { + var d Response[T] + if err := decoder.Decode(&d); err != nil { + if err.Error() == "EOF" { + break + } + return Responses[T]{}, fmt.Errorf("error in parsing json object into Message: %w", err) + } + + if d.Result.Message != "" && d.Result.Code != 0 { + responses.StatusCode = d.Result.Code + } + + if strings.Contains(d.Data.GetMessage(), "CreateError") { + responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "CreateError")) + } + + if strings.Contains(d.Data.GetMessage(), "UpdateError") { + responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "UpdateError")) + } + + if strings.Contains(d.Data.GetMessage(), "DeleteError") { + responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "DeleteError")) + } + + responses.Responses = append(responses.Responses, d) + } + + return responses, nil +} + +func isV2Response(bodyBytes []byte) (bool, error) { + if len(bodyBytes) == 0 { + return false, fmt.Errorf("malformatted response body") + } + + return bodyBytes[0] == '[', nil +} + +func parseStreamingResponseV2[T Message](statusCode int, bodyBytes []byte) ([]T, error) { + var result []T + if err := json.Unmarshal(bodyBytes, &result); err != nil { + return result, fmt.Errorf("failed to read response body: %w", err) + } + + return result, nil } diff --git a/sdk/edgeconnect/v2/appinstance_test.go b/sdk/edgeconnect/v2/appinstance_test.go index e1c3d5e..04df669 100644 --- a/sdk/edgeconnect/v2/appinstance_test.go +++ b/sdk/edgeconnect/v2/appinstance_test.go @@ -126,7 +126,7 @@ func TestCreateAppInstance(t *testing.T) { assert.Equal(t, "application/json", r.Header.Get("Content-Type")) w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) })) defer server.Close() @@ -157,6 +157,7 @@ func TestShowAppInstance(t *testing.T) { tests := []struct { name string appInstKey AppInstanceKey + appKey AppKey region string mockStatusCode int mockResponse string @@ -173,6 +174,7 @@ func TestShowAppInstance(t *testing.T) { Name: "testcloudlet", }, }, + appKey: AppKey{Name: "testapp"}, region: "us-west", mockStatusCode: 200, mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}} @@ -190,6 +192,7 @@ func TestShowAppInstance(t *testing.T) { Name: "testcloudlet", }, }, + appKey: AppKey{Name: "testapp"}, region: "us-west", mockStatusCode: 404, mockResponse: "", @@ -207,7 +210,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() @@ -219,7 +222,7 @@ func TestShowAppInstance(t *testing.T) { // Execute test ctx := context.Background() - appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region) + appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region) // Verify results if tt.expectError { @@ -254,14 +257,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"}, "us-west") + appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west") require.NoError(t, err) assert.Len(t, appInstances, 2) @@ -361,7 +364,7 @@ func TestUpdateAppInstance(t *testing.T) { assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization) w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) })) defer server.Close() diff --git a/sdk/edgeconnect/v2/apps.go b/sdk/edgeconnect/v2/apps.go index 06d529f..61c1f4c 100644 --- a/sdk/edgeconnect/v2/apps.go +++ b/sdk/edgeconnect/v2/apps.go @@ -4,14 +4,12 @@ package v2 import ( - "bytes" "context" - "encoding/json" "fmt" "io" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http" ) var ( @@ -29,7 +27,9 @@ func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error { if err != nil { return fmt.Errorf("CreateApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "CreateApp") @@ -56,7 +56,9 @@ func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App if err != nil { return App{}, fmt.Errorf("ShowApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", @@ -69,7 +71,7 @@ func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App // Parse streaming JSON response var apps []App - if err := c.parseStreamingResponse(resp, &apps); err != nil { + if apps, err = parseStreamingResponse[App](resp); err != nil { return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err) } @@ -96,18 +98,20 @@ func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([] if err != nil { return nil, fmt.Errorf("ShowApps failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { return nil, c.handleErrorResponse(resp, "ShowApps") } - var apps []App if resp.StatusCode == http.StatusNotFound { - return apps, nil // Return empty slice for not found + return []App{}, nil // Return empty slice for not found } - if err := c.parseStreamingResponse(resp, &apps); err != nil { + var apps []App + if apps, err = parseStreamingResponse[App](resp); err != nil { return nil, fmt.Errorf("ShowApps failed to parse response: %w", err) } @@ -125,7 +129,9 @@ func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error { if err != nil { return fmt.Errorf("UpdateApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "UpdateApp") @@ -152,7 +158,9 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er if err != nil { return fmt.Errorf("DeleteApp failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // 404 is acceptable for delete operations (already deleted) if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { @@ -165,70 +173,6 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er return nil } -// parseStreamingResponse parses the EdgeXR streaming JSON response format -func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error { - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - // Try parsing as a direct JSON array first (v2 API format) - switch v := result.(type) { - case *[]App: - var apps []App - if err := json.Unmarshal(bodyBytes, &apps); err == nil { - *v = apps - return nil - } - } - - // Fall back to streaming format (v1 API format) - var responses []Response[App] - var apps []App - var messages []string - - parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { - var response Response[App] - if err := json.Unmarshal(line, &response); err != nil { - return err - } - responses = append(responses, response) - return nil - }) - - if parseErr != nil { - return parseErr - } - - // Extract data from responses - for _, response := range responses { - if response.HasData() { - apps = append(apps, response.Data) - } - if response.IsMessage() { - messages = append(messages, response.Data.GetMessage()) - } - } - - // If we have error messages, return them - if len(messages) > 0 { - return &APIError{ - StatusCode: resp.StatusCode, - Messages: messages, - } - } - - // Set result based on type - switch v := result.(type) { - case *[]App: - *v = apps - default: - return fmt.Errorf("unsupported result type: %T", result) - } - - return nil -} - // getTransport creates an HTTP transport with current client settings func (c *Client) getTransport() *sdkhttp.Transport { return sdkhttp.NewTransport( @@ -254,7 +198,9 @@ func (c *Client) handleErrorResponse(resp *http.Response, operation string) erro bodyBytes := []byte{} if resp.Body != nil { - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() bodyBytes, _ = io.ReadAll(resp.Body) messages = append(messages, string(bodyBytes)) } diff --git a/sdk/edgeconnect/v2/apps_test.go b/sdk/edgeconnect/v2/apps_test.go index 4ea757c..a4c202f 100644 --- a/sdk/edgeconnect/v2/apps_test.go +++ b/sdk/edgeconnect/v2/apps_test.go @@ -67,7 +67,7 @@ func TestCreateApp(t *testing.T) { assert.Equal(t, "application/json", r.Header.Get("Content-Type")) w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) })) defer server.Close() @@ -139,7 +139,7 @@ func TestShowApp(t *testing.T) { w.WriteHeader(tt.mockStatusCode) if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) } })) defer server.Close() @@ -186,7 +186,7 @@ func TestShowApps(t *testing.T) { {"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}} ` w.WriteHeader(200) - w.Write([]byte(response)) + _, _ = w.Write([]byte(response)) })) defer server.Close() @@ -277,7 +277,7 @@ func TestUpdateApp(t *testing.T) { assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization) w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) })) defer server.Close() @@ -407,13 +407,3 @@ func TestAPIError(t *testing.T) { assert.Equal(t, 400, err.StatusCode) assert.Len(t, err.Messages, 2) } - -// Helper function to create a test server that handles streaming JSON responses -func createStreamingJSONServer(responses []string, statusCode int) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(statusCode) - for _, response := range responses { - w.Write([]byte(response + "\n")) - } - })) -} diff --git a/sdk/edgeconnect/v2/auth.go b/sdk/edgeconnect/v2/auth.go index a1f33a2..f428f64 100644 --- a/sdk/edgeconnect/v2/auth.go +++ b/sdk/edgeconnect/v2/auth.go @@ -138,7 +138,9 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e if err != nil { return "", err } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // Read response body - same as existing implementation body, err := io.ReadAll(resp.Body) diff --git a/sdk/edgeconnect/v2/auth_test.go b/sdk/edgeconnect/v2/auth_test.go index 0fc5b24..34ebcaf 100644 --- a/sdk/edgeconnect/v2/auth_test.go +++ b/sdk/edgeconnect/v2/auth_test.go @@ -56,7 +56,7 @@ func TestUsernamePasswordProvider_Success(t *testing.T) { // Return token response := map[string]string{"token": "dynamic-token-456"} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) })) defer loginServer.Close() @@ -75,7 +75,7 @@ func TestUsernamePasswordProvider_LoginFailure(t *testing.T) { // Mock login server that returns error loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("Invalid credentials")) + _, _ = w.Write([]byte("Invalid credentials")) })) defer loginServer.Close() @@ -99,7 +99,7 @@ func TestUsernamePasswordProvider_TokenCaching(t *testing.T) { callCount++ response := map[string]string{"token": "cached-token-789"} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) })) defer loginServer.Close() @@ -128,7 +128,7 @@ func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) { callCount++ response := map[string]string{"token": "refreshed-token-999"} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) })) defer loginServer.Close() @@ -157,7 +157,7 @@ func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) { callCount++ response := map[string]string{"token": "new-token-after-invalidation"} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) })) defer loginServer.Close() @@ -185,7 +185,7 @@ func TestUsernamePasswordProvider_BadJSONResponse(t *testing.T) { // Mock server returning invalid JSON loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Write([]byte("invalid json response")) + _, _ = w.Write([]byte("invalid json response")) })) defer loginServer.Close() diff --git a/sdk/edgeconnect/v2/cloudlet.go b/sdk/edgeconnect/v2/cloudlet.go index 85ef522..c877486 100644 --- a/sdk/edgeconnect/v2/cloudlet.go +++ b/sdk/edgeconnect/v2/cloudlet.go @@ -9,7 +9,7 @@ import ( "fmt" "net/http" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http" ) // CreateCloudlet creates a new cloudlet in the specified region @@ -22,7 +22,9 @@ func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) er if err != nil { return fmt.Errorf("CreateCloudlet failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return c.handleErrorResponse(resp, "CreateCloudlet") @@ -49,7 +51,9 @@ func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, regi if err != nil { return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", @@ -89,7 +93,9 @@ func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, reg if err != nil { return nil, fmt.Errorf("ShowCloudlets failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { return nil, c.handleErrorResponse(resp, "ShowCloudlets") @@ -123,7 +129,9 @@ func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, re if err != nil { return fmt.Errorf("DeleteCloudlet failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // 404 is acceptable for delete operations (already deleted) if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { @@ -151,7 +159,9 @@ func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKe if err != nil { return nil, fmt.Errorf("GetCloudletManifest failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w", @@ -189,7 +199,9 @@ func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey Cloud if err != nil { return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w", diff --git a/sdk/edgeconnect/v2/cloudlet_test.go b/sdk/edgeconnect/v2/cloudlet_test.go index 8f2cc06..d8ffb75 100644 --- a/sdk/edgeconnect/v2/cloudlet_test.go +++ b/sdk/edgeconnect/v2/cloudlet_test.go @@ -70,7 +70,7 @@ func TestCreateCloudlet(t *testing.T) { assert.Equal(t, "application/json", r.Header.Get("Content-Type")) w.WriteHeader(tt.mockStatusCode) - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) })) defer server.Close() @@ -140,7 +140,7 @@ func TestShowCloudlet(t *testing.T) { w.WriteHeader(tt.mockStatusCode) if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) } })) defer server.Close() @@ -187,7 +187,7 @@ func TestShowCloudlets(t *testing.T) { {"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}} ` w.WriteHeader(200) - w.Write([]byte(response)) + _, _ = w.Write([]byte(response)) })) defer server.Close() @@ -312,7 +312,7 @@ func TestGetCloudletManifest(t *testing.T) { w.WriteHeader(tt.mockStatusCode) if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) } })) defer server.Close() @@ -380,7 +380,7 @@ func TestGetCloudletResourceUsage(t *testing.T) { w.WriteHeader(tt.mockStatusCode) if tt.mockResponse != "" { - w.Write([]byte(tt.mockResponse)) + _, _ = w.Write([]byte(tt.mockResponse)) } })) defer server.Close() diff --git a/sdk/edgeconnect/v2/types.go b/sdk/edgeconnect/v2/types.go index 0bb6875..7dea92e 100644 --- a/sdk/edgeconnect/v2/types.go +++ b/sdk/edgeconnect/v2/types.go @@ -60,74 +60,74 @@ const ( // AppInstance field constants for partial updates (based on EdgeXR API specification) const ( - AppInstFieldKey = "2" - AppInstFieldKeyAppKey = "2.1" - AppInstFieldKeyAppKeyOrganization = "2.1.1" - AppInstFieldKeyAppKeyName = "2.1.2" - AppInstFieldKeyAppKeyVersion = "2.1.3" - AppInstFieldKeyClusterInstKey = "2.4" - AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1" - AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1" - AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2" - AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1" - AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2" + AppInstFieldKey = "2" + AppInstFieldKeyAppKey = "2.1" + AppInstFieldKeyAppKeyOrganization = "2.1.1" + AppInstFieldKeyAppKeyName = "2.1.2" + AppInstFieldKeyAppKeyVersion = "2.1.3" + AppInstFieldKeyClusterInstKey = "2.4" + AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1" + AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1" + AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2" + AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1" + AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2" AppInstFieldKeyClusterInstKeyCloudletKeyFederatedOrganization = "2.4.2.3" - AppInstFieldKeyClusterInstKeyOrganization = "2.4.3" - AppInstFieldCloudletLoc = "3" - AppInstFieldCloudletLocLatitude = "3.1" - AppInstFieldCloudletLocLongitude = "3.2" - AppInstFieldCloudletLocHorizontalAccuracy = "3.3" - AppInstFieldCloudletLocVerticalAccuracy = "3.4" - AppInstFieldCloudletLocAltitude = "3.5" - AppInstFieldCloudletLocCourse = "3.6" - AppInstFieldCloudletLocSpeed = "3.7" - AppInstFieldCloudletLocTimestamp = "3.8" - AppInstFieldCloudletLocTimestampSeconds = "3.8.1" - AppInstFieldCloudletLocTimestampNanos = "3.8.2" - AppInstFieldUri = "4" - AppInstFieldLiveness = "6" - AppInstFieldMappedPorts = "9" - AppInstFieldMappedPortsProto = "9.1" - AppInstFieldMappedPortsInternalPort = "9.2" - AppInstFieldMappedPortsPublicPort = "9.3" - AppInstFieldMappedPortsFqdnPrefix = "9.5" - AppInstFieldMappedPortsEndPort = "9.6" - AppInstFieldMappedPortsTls = "9.7" - AppInstFieldMappedPortsNginx = "9.8" - AppInstFieldMappedPortsMaxPktSize = "9.9" - AppInstFieldFlavor = "12" - AppInstFieldFlavorName = "12.1" - AppInstFieldState = "14" - AppInstFieldErrors = "15" - AppInstFieldCrmOverride = "16" - AppInstFieldRuntimeInfo = "17" - AppInstFieldRuntimeInfoContainerIds = "17.1" - AppInstFieldCreatedAt = "21" - AppInstFieldCreatedAtSeconds = "21.1" - AppInstFieldCreatedAtNanos = "21.2" - AppInstFieldAutoClusterIpAccess = "22" - AppInstFieldRevision = "24" - AppInstFieldForceUpdate = "25" - AppInstFieldUpdateMultiple = "26" - AppInstFieldConfigs = "27" - AppInstFieldConfigsKind = "27.1" - AppInstFieldConfigsConfig = "27.2" - AppInstFieldHealthCheck = "29" - AppInstFieldPowerState = "31" - AppInstFieldExternalVolumeSize = "32" - AppInstFieldAvailabilityZone = "33" - AppInstFieldVmFlavor = "34" - AppInstFieldOptRes = "35" - AppInstFieldUpdatedAt = "36" - AppInstFieldUpdatedAtSeconds = "36.1" - AppInstFieldUpdatedAtNanos = "36.2" - AppInstFieldRealClusterName = "37" - AppInstFieldInternalPortToLbIp = "38" - AppInstFieldInternalPortToLbIpKey = "38.1" - AppInstFieldInternalPortToLbIpValue = "38.2" - AppInstFieldDedicatedIp = "39" - AppInstFieldUniqueId = "40" - AppInstFieldDnsLabel = "41" + AppInstFieldKeyClusterInstKeyOrganization = "2.4.3" + AppInstFieldCloudletLoc = "3" + AppInstFieldCloudletLocLatitude = "3.1" + AppInstFieldCloudletLocLongitude = "3.2" + AppInstFieldCloudletLocHorizontalAccuracy = "3.3" + AppInstFieldCloudletLocVerticalAccuracy = "3.4" + AppInstFieldCloudletLocAltitude = "3.5" + AppInstFieldCloudletLocCourse = "3.6" + AppInstFieldCloudletLocSpeed = "3.7" + AppInstFieldCloudletLocTimestamp = "3.8" + AppInstFieldCloudletLocTimestampSeconds = "3.8.1" + AppInstFieldCloudletLocTimestampNanos = "3.8.2" + AppInstFieldUri = "4" + AppInstFieldLiveness = "6" + AppInstFieldMappedPorts = "9" + AppInstFieldMappedPortsProto = "9.1" + AppInstFieldMappedPortsInternalPort = "9.2" + AppInstFieldMappedPortsPublicPort = "9.3" + AppInstFieldMappedPortsFqdnPrefix = "9.5" + AppInstFieldMappedPortsEndPort = "9.6" + AppInstFieldMappedPortsTls = "9.7" + AppInstFieldMappedPortsNginx = "9.8" + AppInstFieldMappedPortsMaxPktSize = "9.9" + AppInstFieldFlavor = "12" + AppInstFieldFlavorName = "12.1" + AppInstFieldState = "14" + AppInstFieldErrors = "15" + AppInstFieldCrmOverride = "16" + AppInstFieldRuntimeInfo = "17" + AppInstFieldRuntimeInfoContainerIds = "17.1" + AppInstFieldCreatedAt = "21" + AppInstFieldCreatedAtSeconds = "21.1" + AppInstFieldCreatedAtNanos = "21.2" + AppInstFieldAutoClusterIpAccess = "22" + AppInstFieldRevision = "24" + AppInstFieldForceUpdate = "25" + AppInstFieldUpdateMultiple = "26" + AppInstFieldConfigs = "27" + AppInstFieldConfigsKind = "27.1" + AppInstFieldConfigsConfig = "27.2" + AppInstFieldHealthCheck = "29" + AppInstFieldPowerState = "31" + AppInstFieldExternalVolumeSize = "32" + AppInstFieldAvailabilityZone = "33" + AppInstFieldVmFlavor = "34" + AppInstFieldOptRes = "35" + AppInstFieldUpdatedAt = "36" + AppInstFieldUpdatedAtSeconds = "36.1" + AppInstFieldUpdatedAtNanos = "36.2" + AppInstFieldRealClusterName = "37" + AppInstFieldInternalPortToLbIp = "38" + AppInstFieldInternalPortToLbIpKey = "38.1" + AppInstFieldInternalPortToLbIpValue = "38.2" + AppInstFieldDedicatedIp = "39" + AppInstFieldUniqueId = "40" + AppInstFieldDnsLabel = "41" ) // Message interface for types that can provide error messages @@ -291,7 +291,8 @@ type DeleteAppInstanceInput struct { // Response wraps a single API response type Response[T Message] struct { - Data T `json:"data"` + ResultResponse `json:",inline"` + Data T `json:"data"` } func (res *Response[T]) HasData() bool { @@ -326,6 +327,7 @@ func (r *ResultResponse) GetCode() int { type Responses[T Message] struct { Responses []Response[T] `json:"responses,omitempty"` StatusCode int `json:"-"` + Errors []error `json:"-"` } func (r *Responses[T]) GetData() []T { @@ -344,12 +346,15 @@ func (r *Responses[T]) GetMessages() []string { if v.IsMessage() { messages = append(messages, v.Data.GetMessage()) } + if v.Result.Message != "" { + messages = append(messages, v.Result.Message) + } } return messages } func (r *Responses[T]) IsSuccessful() bool { - return r.StatusCode >= 200 && r.StatusCode < 400 + return len(r.Errors) == 0 && (r.StatusCode >= 200 && r.StatusCode < 400) } func (r *Responses[T]) Error() error { @@ -410,3 +415,7 @@ type CloudletResourceUsage struct { Region string `json:"region"` Usage map[string]interface{} `json:"usage"` } + +type ErrorMessage struct { + Message string +} diff --git a/sdk/examples/comprehensive/k8s-deployment.yaml b/sdk/examples/comprehensive/k8s-deployment.yaml index 2a0a741..dff3649 100644 --- a/sdk/examples/comprehensive/k8s-deployment.yaml +++ b/sdk/examples/comprehensive/k8s-deployment.yaml @@ -18,6 +18,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: edgeconnect-coder-deployment + #namespace: gitea spec: replicas: 1 selector: diff --git a/sdk/examples/comprehensive/main.go b/sdk/examples/comprehensive/main.go index d3fb922..25a4aa5 100644 --- a/sdk/examples/comprehensive/main.go +++ b/sdk/examples/comprehensive/main.go @@ -12,7 +12,7 @@ import ( "strings" "time" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ) func main() { @@ -193,7 +193,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow }, } - instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, config.Region, 5*time.Minute) + instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, v2.AppKey{}, config.Region, 5*time.Minute) if err != nil { return fmt.Errorf("failed to wait for instance ready: %w", err) } @@ -207,7 +207,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow // 6. List Application Instances fmt.Println("\n6️⃣ Listing application instances...") - instances, err := c.ShowAppInstances(ctx, v2.AppInstanceKey{Organization: config.Organization}, config.Region) + instances, err := c.ShowAppInstances(ctx, v2.AppInstanceKey{Organization: config.Organization}, v2.AppKey{}, config.Region) if err != nil { return fmt.Errorf("failed to list app instances: %w", err) } @@ -306,7 +306,7 @@ func getEnvOrDefault(key, defaultValue string) string { } // waitForInstanceReady polls the instance status until it's no longer "Creating" or timeout -func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppInstanceKey, region string, timeout time.Duration) (v2.AppInstance, error) { +func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string, timeout time.Duration) (v2.AppInstance, error) { timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -321,7 +321,7 @@ func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppI return v2.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout) case <-ticker.C: - instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, region) + instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, appKey, region) if err != nil { // Log error but continue polling fmt.Printf(" ⚠️ Error checking instance state: %v\n", err) diff --git a/sdk/examples/deploy_app.go b/sdk/examples/deploy_app.go index 84297dc..d35ff9c 100644 --- a/sdk/examples/deploy_app.go +++ b/sdk/examples/deploy_app.go @@ -12,7 +12,7 @@ import ( "strings" "time" - v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" ) func main() { diff --git a/sdk/examples/forgejo-runner/EdgeConnectConfig.yaml b/sdk/examples/forgejo-runner/EdgeConnectConfig_v1.yaml similarity index 100% rename from sdk/examples/forgejo-runner/EdgeConnectConfig.yaml rename to sdk/examples/forgejo-runner/EdgeConnectConfig_v1.yaml diff --git a/sdk/examples/forgejo-runner/EdgeConnectConfig_v2.yaml b/sdk/examples/forgejo-runner/EdgeConnectConfig_v2.yaml new file mode 100644 index 0000000..5afcf4b --- /dev/null +++ b/sdk/examples/forgejo-runner/EdgeConnectConfig_v2.yaml @@ -0,0 +1,29 @@ +# Is there a swagger file for the new EdgeConnect API? +# How does it differ from the EdgeXR API? +kind: edgeconnect-deployment +metadata: + name: "forgejo-runner-orca" # name could be used for appName + appVersion: "1" + organization: "edp2-orca" +spec: + # dockerApp: # Docker is OBSOLETE + # appVersion: "1.0.0" + # manifestFile: "./docker-compose.yaml" + # image: "https://registry-1.docker.io/library/nginx:latest" + k8sApp: + manifestFile: "./forgejo-runner-deployment.yaml" + infraTemplate: + - region: "US" + cloudletOrg: "TelekomOp" + cloudletName: "gardener-shepherd-test" + flavorName: "defualt" + network: + outboundConnections: + - protocol: "tcp" + portRangeMin: 80 + portRangeMax: 80 + remoteCIDR: "0.0.0.0/0" + - protocol: "tcp" + portRangeMin: 443 + portRangeMax: 443 + remoteCIDR: "0.0.0.0/0" diff --git a/sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v1.yaml b/sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v1.yaml new file mode 100644 index 0000000..9710327 --- /dev/null +++ b/sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v1.yaml @@ -0,0 +1,29 @@ +# Is there a swagger file for the new EdgeConnect API? +# How does it differ from the EdgeXR API? +kind: edgeconnect-deployment +metadata: + name: "edge-ubuntu-buildkit" # name could be used for appName + appVersion: "1.0.0" + organization: "edp2" +spec: + # dockerApp: # Docker is OBSOLETE + # appVersion: "1.0.0" + # manifestFile: "./docker-compose.yaml" + # image: "https://registry-1.docker.io/library/nginx:latest" + k8sApp: + manifestFile: "./k8s-deployment.yaml" + infraTemplate: + - region: "EU" + cloudletOrg: "TelekomOP" + cloudletName: "Munich" + flavorName: "EU.small" + network: + outboundConnections: + - protocol: "tcp" + portRangeMin: 80 + portRangeMax: 80 + remoteCIDR: "0.0.0.0/0" + - protocol: "tcp" + portRangeMin: 443 + portRangeMax: 443 + remoteCIDR: "0.0.0.0/0" diff --git a/sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v2.yaml b/sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v2.yaml new file mode 100644 index 0000000..9fb80df --- /dev/null +++ b/sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v2.yaml @@ -0,0 +1,29 @@ +# Is there a swagger file for the new EdgeConnect API? +# How does it differ from the EdgeXR API? +kind: edgeconnect-deployment +metadata: + name: "edge-ubuntu-buildkit" # name could be used for appName + appVersion: "1" + organization: "edp2-orca" +spec: + # dockerApp: # Docker is OBSOLETE + # appVersion: "1.0.0" + # manifestFile: "./docker-compose.yaml" + # image: "https://registry-1.docker.io/library/nginx:latest" + k8sApp: + manifestFile: "./k8s-deployment.yaml" + infraTemplate: + - region: "US" + cloudletOrg: "TelekomOp" + cloudletName: "gardener-shepherd-test" + flavorName: "defualt" + network: + outboundConnections: + - protocol: "tcp" + portRangeMin: 80 + portRangeMax: 80 + remoteCIDR: "0.0.0.0/0" + - protocol: "tcp" + portRangeMin: 443 + portRangeMax: 443 + remoteCIDR: "0.0.0.0/0" diff --git a/sdk/examples/ubuntu-buildkit/k8s-deployment.yaml b/sdk/examples/ubuntu-buildkit/k8s-deployment.yaml new file mode 100644 index 0000000..d4d3dd8 --- /dev/null +++ b/sdk/examples/ubuntu-buildkit/k8s-deployment.yaml @@ -0,0 +1,57 @@ +# Add remote buildx builder: +# docker buildx create --use --name sidecar tcp://127.0.0.1:1234 + +# Run build: +# docker buildx build . + +apiVersion: v1 +kind: Service +metadata: + name: ubuntu-runner + labels: + run: ubuntu-runner +spec: + type: LoadBalancer + ports: + - name: tcp80 + protocol: TCP + port: 80 + targetPort: 80 + selector: + run: ubuntu-runner +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + creationTimestamp: null + labels: + app: ubuntu-runner + name: ubuntu-runner +spec: + replicas: 1 + selector: + matchLabels: + app: ubuntu-runner + strategy: {} + template: + metadata: + creationTimestamp: null + labels: + app: ubuntu-runner + annotations: + container.apparmor.security.beta.kubernetes.io/buildkitd: unconfined + spec: + containers: + - name: ubuntu + image: edp.buildth.ing/devfw-cicd/catthehacker/ubuntu:act-22.04-amd64 + command: + - sleep + - 7d + - args: + - --allow-insecure-entitlement=network.host + - --oci-worker-no-process-sandbox + - --addr + - tcp://127.0.0.1:1234 + image: moby/buildkit:v0.25.1-rootless + imagePullPolicy: IfNotPresent + name: buildkitd diff --git a/sdk/internal/http/transport.go b/sdk/internal/http/transport.go index c3bbab1..35b71b8 100644 --- a/sdk/internal/http/transport.go +++ b/sdk/internal/http/transport.go @@ -162,7 +162,9 @@ func (t *Transport) CallJSON(ctx context.Context, method, url string, body inter if err != nil { return resp, err } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() // Read response body respBody, err := io.ReadAll(resp.Body)