diff --git a/cmd/app.go b/cmd/app.go index 0273896..a96f599 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -9,7 +9,7 @@ import ( "os" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -50,7 +50,7 @@ func validateBaseURL(baseURL string) error { return nil } -func newSDKClient() *edgeconnect.Client { +func newSDKClient() *v2.Client { baseURL := viper.GetString("base_url") username := viper.GetString("username") password := viper.GetString("password") @@ -62,22 +62,22 @@ func newSDKClient() *edgeconnect.Client { } // Build options - opts := []edgeconnect.Option{ - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + opts := []v2.Option{ + v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), } // Add logger only if debug flag is set if debug { logger := log.New(os.Stderr, "[DEBUG] ", log.LstdFlags) - opts = append(opts, edgeconnect.WithLogger(logger)) + opts = append(opts, v2.WithLogger(logger)) } if username != "" && password != "" { - return edgeconnect.NewClientWithCredentials(baseURL, username, password, opts...) + return v2.NewClientWithCredentials(baseURL, username, password, opts...) } // Fallback to no auth for now - in production should require auth - return edgeconnect.NewClient(baseURL, opts...) + return v2.NewClient(baseURL, opts...) } var appCmd = &cobra.Command{ @@ -91,10 +91,10 @@ var createAppCmd = &cobra.Command{ Short: "Create a new Edge Connect application", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - input := &edgeconnect.NewAppInput{ + input := &v2.NewAppInput{ Region: region, - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ + App: v2.App{ + Key: v2.AppKey{ Organization: organization, Name: appName, Version: appVersion, @@ -116,7 +116,7 @@ var showAppCmd = &cobra.Command{ Short: "Show details of an Edge Connect application", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: organization, Name: appName, Version: appVersion, @@ -136,7 +136,7 @@ var listAppsCmd = &cobra.Command{ Short: "List Edge Connect applications", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: organization, Name: appName, Version: appVersion, @@ -159,7 +159,7 @@ var deleteAppCmd = &cobra.Command{ Short: "Delete an Edge Connect application", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: organization, Name: appName, Version: appVersion, diff --git a/cmd/instance.go b/cmd/instance.go index de22062..30194ab 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" "github.com/spf13/cobra" ) @@ -27,23 +27,23 @@ var createInstanceCmd = &cobra.Command{ Short: "Create a new Edge Connect application instance", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - input := &edgeconnect.NewAppInstanceInput{ + input := &v2.NewAppInstanceInput{ Region: region, - AppInst: edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ + AppInst: v2.AppInstance{ + Key: v2.AppInstanceKey{ Organization: organization, Name: instanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: cloudletOrg, Name: cloudletName, }, }, - AppKey: edgeconnect.AppKey{ + AppKey: v2.AppKey{ Organization: organization, Name: appName, Version: appVersion, }, - Flavor: edgeconnect.Flavor{ + Flavor: v2.Flavor{ Name: flavorName, }, }, @@ -63,10 +63,10 @@ var showInstanceCmd = &cobra.Command{ Short: "Show details of an Edge Connect application instance", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: organization, Name: instanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: cloudletOrg, Name: cloudletName, }, @@ -86,10 +86,10 @@ var listInstancesCmd = &cobra.Command{ Short: "List Edge Connect application instances", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: organization, Name: instanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: cloudletOrg, Name: cloudletName, }, @@ -112,10 +112,10 @@ var deleteInstanceCmd = &cobra.Command{ Short: "Delete an Edge Connect application instance", Run: func(cmd *cobra.Command, args []string) { c := newSDKClient() - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: organization, Name: instanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: cloudletOrg, Name: cloudletName, }, diff --git a/internal/apply/manager.go b/internal/apply/manager.go index 45477ab..3e6d837 100644 --- a/internal/apply/manager.go +++ b/internal/apply/manager.go @@ -8,7 +8,7 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ) // ResourceManagerInterface defines the interface for resource management @@ -250,7 +250,7 @@ func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context, // rollbackApp deletes an application that was created func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error { - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: plan.AppAction.Desired.Organization, Name: plan.AppAction.Desired.Name, Version: plan.AppAction.Desired.Version, @@ -264,10 +264,10 @@ func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, acti // Find the instance action to get the details for _, instanceAction := range plan.InstanceActions { if instanceAction.InstanceName == action.Target { - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: plan.AppAction.Desired.Organization, Name: instanceAction.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: instanceAction.Target.CloudletOrg, Name: instanceAction.Target.CloudletName, }, diff --git a/internal/apply/manager_test.go b/internal/apply/manager_test.go index 6060a37..f2135b5 100644 --- a/internal/apply/manager_test.go +++ b/internal/apply/manager_test.go @@ -11,7 +11,7 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -22,32 +22,32 @@ type MockResourceClient struct { MockEdgeConnectClient } -func (m *MockResourceClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { +func (m *MockResourceClient) CreateApp(ctx context.Context, input *v2.NewAppInput) error { args := m.Called(ctx, input) return args.Error(0) } -func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error { +func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error { args := m.Called(ctx, input) return args.Error(0) } -func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error { +func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error { args := m.Called(ctx, appKey, region) return args.Error(0) } -func (m *MockResourceClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error { +func (m *MockResourceClient) UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error { args := m.Called(ctx, input) return args.Error(0) } -func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error { +func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error { args := m.Called(ctx, input) return args.Error(0) } -func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error { +func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error { args := m.Called(ctx, instanceKey, region) return args.Error(0) } @@ -185,9 +185,9 @@ func TestApplyDeploymentSuccess(t *testing.T) { config := createTestManagerConfig(t) // Mock successful operations - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). + mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")). Return(nil) - mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). + mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")). Return(nil) ctx := context.Background() @@ -216,8 +216,8 @@ func TestApplyDeploymentAppFailure(t *testing.T) { config := createTestManagerConfig(t) // Mock app creation failure - deployment should stop here - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) + mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")). + Return(&v2.APIError{StatusCode: 500, Messages: []string{"Server error"}}) ctx := context.Background() result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content") @@ -241,13 +241,13 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { config := createTestManagerConfig(t) // Mock successful app creation but failed instance creation - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). + mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")). Return(nil) - mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}}) + mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")). + Return(&v2.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}}) // Mock rollback operations - mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). + mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). Return(nil) ctx := context.Background() @@ -333,9 +333,9 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) { config := createTestManagerConfig(t) // Mock successful operations - mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). + mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")). Return(nil) - mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). + mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")). Return(nil) ctx := context.Background() @@ -421,9 +421,9 @@ func TestRollbackDeployment(t *testing.T) { } // Mock rollback operations - mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). + mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US"). Return(nil) - mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). + mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). Return(nil) ctx := context.Background() @@ -453,8 +453,8 @@ func TestRollbackDeploymentFailure(t *testing.T) { } // Mock rollback failure - mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Delete failed"}}) + mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). + Return(&v2.APIError{StatusCode: 500, Messages: []string{"Delete failed"}}) ctx := context.Background() err := manager.RollbackDeployment(ctx, result) diff --git a/internal/apply/planner.go b/internal/apply/planner.go index 1cbc58d..d4f3e82 100644 --- a/internal/apply/planner.go +++ b/internal/apply/planner.go @@ -12,19 +12,19 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ) // EdgeConnectClientInterface defines the methods needed for deployment planning type EdgeConnectClientInterface interface { - ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) - CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error - UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error - DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error - ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) - CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error - UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error - DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error + ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) + CreateApp(ctx context.Context, input *v2.NewAppInput) error + UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error + DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error + ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) (v2.AppInstance, error) + CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error + UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error + DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error } // Planner defines the interface for deployment planning @@ -285,7 +285,7 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: desired.Organization, Name: desired.Name, Version: desired.Version, @@ -339,10 +339,10 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: desired.Organization, Name: desired.Name, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: desired.CloudletOrg, Name: desired.CloudletName, }, diff --git a/internal/apply/planner_test.go b/internal/apply/planner_test.go index d946a14..6f7c39b 100644 --- a/internal/apply/planner_test.go +++ b/internal/apply/planner_test.go @@ -10,7 +10,7 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -21,66 +21,66 @@ type MockEdgeConnectClient struct { mock.Mock } -func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) { +func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) { args := m.Called(ctx, appKey, region) if args.Get(0) == nil { - return edgeconnect.App{}, args.Error(1) + return v2.App{}, args.Error(1) } - return args.Get(0).(edgeconnect.App), args.Error(1) + return args.Get(0).(v2.App), args.Error(1) } -func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) { +func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) (v2.AppInstance, error) { args := m.Called(ctx, instanceKey, region) if args.Get(0) == nil { - return edgeconnect.AppInstance{}, args.Error(1) + return v2.AppInstance{}, args.Error(1) } - return args.Get(0).(edgeconnect.AppInstance), args.Error(1) + return args.Get(0).(v2.AppInstance), args.Error(1) } -func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { +func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *v2.NewAppInput) error { args := m.Called(ctx, input) return args.Error(0) } -func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error { +func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error { args := m.Called(ctx, input) return args.Error(0) } -func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error { +func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error { args := m.Called(ctx, appKey, region) return args.Error(0) } -func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error { +func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error { args := m.Called(ctx, input) return args.Error(0) } -func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error { +func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error { args := m.Called(ctx, input) return args.Error(0) } -func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error { +func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error { args := m.Called(ctx, instanceKey, region) return args.Error(0) } -func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect.AppKey, region string) ([]edgeconnect.App, error) { +func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey v2.AppKey, region string) ([]v2.App, error) { args := m.Called(ctx, appKey, region) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]edgeconnect.App), args.Error(1) + return args.Get(0).([]v2.App), args.Error(1) } -func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) { +func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) { args := m.Called(ctx, instanceKey, region) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]edgeconnect.AppInstance), args.Error(1) + return args.Get(0).([]v2.AppInstance), args.Error(1) } func TestNewPlanner(t *testing.T) { @@ -148,11 +148,11 @@ func TestPlanNewDeployment(t *testing.T) { testConfig := createTestConfig(t) // Mock API calls to return "not found" errors - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). + Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}}) - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) + mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US"). + Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) ctx := context.Background() result, err := planner.Plan(ctx, testConfig) @@ -186,15 +186,15 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) { // Mock existing app with same manifest hash and outbound connections manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" - existingApp := &edgeconnect.App{ - Key: edgeconnect.AppKey{ + existingApp := &v2.App{ + Key: v2.AppKey{ Organization: "testorg", Name: "test-app", Version: "1.0.0", }, Deployment: "kubernetes", DeploymentManifest: manifestContent, - RequiredOutboundConnections: []edgeconnect.SecurityRule{ + RequiredOutboundConnections: []v2.SecurityRule{ { Protocol: "tcp", PortRangeMin: 80, @@ -206,31 +206,31 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) { } // Mock existing instance - existingInstance := &edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ + existingInstance := &v2.AppInstance{ + Key: v2.AppInstanceKey{ Organization: "testorg", Name: "test-app-1.0.0-instance", - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: "TestCloudletOrg", Name: "TestCloudlet", }, }, - AppKey: edgeconnect.AppKey{ + AppKey: v2.AppKey{ Organization: "testorg", Name: "test-app", Version: "1.0.0", }, - Flavor: edgeconnect.Flavor{ + Flavor: v2.Flavor{ Name: "small", }, State: "Ready", PowerState: "PowerOn", } - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). Return(*existingApp, nil) - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). + mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US"). Return(*existingInstance, nil) ctx := context.Background() @@ -293,14 +293,14 @@ func TestPlanMultipleInfrastructures(t *testing.T) { }) // Mock API calls to return "not found" errors - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). + Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}}) - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) + mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US"). + Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) - mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "EU"). - Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) + mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "EU"). + Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) ctx := context.Background() result, err := planner.Plan(ctx, testConfig) @@ -628,10 +628,10 @@ func TestIsResourceNotFoundError(t *testing.T) { expected bool }{ {"nil error", nil, false}, - {"not found error", &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true}, - {"does not exist error", &edgeconnect.APIError{Messages: []string{"App does not exist"}}, true}, - {"404 in message", &edgeconnect.APIError{Messages: []string{"HTTP 404 error"}}, true}, - {"other error", &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false}, + {"not found error", &v2.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true}, + {"does not exist error", &v2.APIError{Messages: []string{"App does not exist"}}, true}, + {"404 in message", &v2.APIError{Messages: []string{"HTTP 404 error"}}, true}, + {"other error", &v2.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false}, } for _, tt := range tests { @@ -648,8 +648,8 @@ func TestPlanErrorHandling(t *testing.T) { testConfig := createTestConfig(t) // Mock API call to return a non-404 error - mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). - Return(nil, &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) + mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US"). + Return(nil, &v2.APIError{StatusCode: 500, Messages: []string{"Server error"}}) ctx := context.Background() result, err := planner.Plan(ctx, testConfig) diff --git a/internal/apply/strategy_recreate.go b/internal/apply/strategy_recreate.go index 4e69e7d..dc44784 100644 --- a/internal/apply/strategy_recreate.go +++ b/internal/apply/strategy_recreate.go @@ -11,7 +11,7 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ) // RecreateStrategy implements the recreate deployment strategy @@ -184,7 +184,7 @@ func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentP r.logf("Phase 2: Deleting existing application") - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: plan.AppAction.Desired.Organization, Name: plan.AppAction.Desired.Name, Version: plan.AppAction.Desired.Version, @@ -426,10 +426,10 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action // deleteInstance deletes an instance (reuse existing logic from manager.go) func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) { - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: action.Desired.Organization, Name: action.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: action.Target.CloudletOrg, Name: action.Target.CloudletName, }, @@ -445,23 +445,23 @@ func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAc // createInstance creates an instance (extracted from manager.go logic) func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) { - instanceInput := &edgeconnect.NewAppInstanceInput{ + instanceInput := &v2.NewAppInstanceInput{ Region: action.Target.Region, - AppInst: edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ + AppInst: v2.AppInstance{ + Key: v2.AppInstanceKey{ Organization: action.Desired.Organization, Name: action.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: action.Target.CloudletOrg, Name: action.Target.CloudletName, }, }, - AppKey: edgeconnect.AppKey{ + AppKey: v2.AppKey{ Organization: action.Desired.Organization, Name: config.Metadata.Name, Version: config.Metadata.AppVersion, }, - Flavor: edgeconnect.Flavor{ + Flavor: v2.Flavor{ Name: action.Target.FlavorName, }, }, @@ -481,10 +481,10 @@ func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAc // updateApplication creates/recreates an application (always uses CreateApp since we delete first) func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) { // Build the app create input - always create since recreate strategy deletes first - appInput := &edgeconnect.NewAppInput{ + appInput := &v2.NewAppInput{ Region: action.Desired.Region, - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ + App: v2.App{ + Key: v2.AppKey{ Organization: action.Desired.Organization, Name: action.Desired.Name, Version: action.Desired.Version, @@ -493,7 +493,7 @@ func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppActi ImageType: "ImageTypeDocker", ImagePath: config.GetImagePath(), AllowServerless: true, - DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, + DefaultFlavor: v2.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, ServerlessConfig: struct{}{}, DeploymentManifest: manifestContent, DeploymentGenerator: "kubernetes-basic", @@ -531,7 +531,7 @@ func isRetryableError(err error) bool { } // Check if it's an APIError with a status code - var apiErr *edgeconnect.APIError + var apiErr *v2.APIError if errors.As(err, &apiErr) { // Don't retry client errors (4xx) if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { diff --git a/internal/apply/types.go b/internal/apply/types.go index 6f7ef4e..279832a 100644 --- a/internal/apply/types.go +++ b/internal/apply/types.go @@ -8,11 +8,11 @@ import ( "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ) // SecurityRule defines network access rules (alias to SDK type for consistency) -type SecurityRule = edgeconnect.SecurityRule +type SecurityRule = v2.SecurityRule // ActionType represents the type of action to be performed type ActionType string @@ -446,11 +446,11 @@ func (dp *DeploymentPlan) Clone() *DeploymentPlan { } // convertNetworkRules converts config network rules to EdgeConnect SecurityRules -func convertNetworkRules(network *config.NetworkConfig) []edgeconnect.SecurityRule { - rules := make([]edgeconnect.SecurityRule, len(network.OutboundConnections)) +func convertNetworkRules(network *config.NetworkConfig) []v2.SecurityRule { + rules := make([]v2.SecurityRule, len(network.OutboundConnections)) for i, conn := range network.OutboundConnections { - rules[i] = edgeconnect.SecurityRule{ + rules[i] = v2.SecurityRule{ Protocol: conn.Protocol, PortRangeMin: conn.PortRangeMin, PortRangeMax: conn.PortRangeMax, diff --git a/sdk/README.md b/sdk/README.md index 0f16b12..89dc673 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -16,18 +16,18 @@ A comprehensive Go SDK for the EdgeXR Master Controller API, providing typed int ### Installation ```go -import "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" +import v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ``` ### Authentication ```go // Username/password (recommended) -client := client.NewClientWithCredentials(baseURL, username, password) +client := v2.NewClientWithCredentials(baseURL, username, password) // Static Bearer token -client := client.NewClient(baseURL, - client.WithAuthProvider(client.NewStaticTokenProvider(token))) +client := v2.NewClient(baseURL, + v2.WithAuthProvider(v2.NewStaticTokenProvider(token))) ``` ### Basic Usage @@ -36,10 +36,10 @@ client := client.NewClient(baseURL, ctx := context.Background() // Create an application -app := &client.NewAppInput{ +app := &v2.NewAppInput{ Region: "us-west", - App: client.App{ - Key: client.AppKey{ + App: v2.App{ + Key: v2.AppKey{ Organization: "myorg", Name: "my-app", Version: "1.0.0", @@ -49,28 +49,28 @@ app := &client.NewAppInput{ }, } -if err := client.CreateApp(ctx, app); err != nil { +if err := v2.CreateApp(ctx, app); err != nil { log.Fatal(err) } // Deploy an application instance -instance := &client.NewAppInstanceInput{ +instance := &v2.NewAppInstanceInput{ Region: "us-west", - AppInst: client.AppInstance{ - Key: client.AppInstanceKey{ + AppInst: v2.AppInstance{ + Key: v2.AppInstanceKey{ Organization: "myorg", Name: "my-instance", - CloudletKey: client.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: "cloudlet-provider", Name: "edge-cloudlet", }, }, AppKey: app.App.Key, - Flavor: client.Flavor{Name: "m4.small"}, + Flavor: v2.Flavor{Name: "m4.small"}, }, } -if err := client.CreateAppInstance(ctx, instance); err != nil { +if err := v2.CreateAppInstance(ctx, instance); err != nil { log.Fatal(err) } ``` @@ -101,22 +101,22 @@ if err := client.CreateAppInstance(ctx, instance); err != nil { ## Configuration Options ```go -client := client.NewClient(baseURL, +client := v2.NewClient(baseURL, // Custom HTTP client with timeout - client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), // Authentication provider - client.WithAuthProvider(client.NewStaticTokenProvider(token)), + v2.WithAuthProvider(v2.NewStaticTokenProvider(token)), // Retry configuration - client.WithRetryOptions(client.RetryOptions{ + v2.WithRetryOptions(v2.RetryOptions{ MaxRetries: 5, InitialDelay: 1 * time.Second, MaxDelay: 30 * time.Second, }), // Request logging - client.WithLogger(log.Default()), + v2.WithLogger(log.Default()), ) ``` @@ -141,7 +141,7 @@ EDGEXR_USERNAME=user EDGEXR_PASSWORD=pass go run main.go Uses the existing `/api/v1/login` endpoint with automatic token caching: ```go -client := client.NewClientWithCredentials(baseURL, username, password) +client := v2.NewClientWithCredentials(baseURL, username, password) ``` **Features:** @@ -154,23 +154,23 @@ client := client.NewClientWithCredentials(baseURL, username, password) For pre-obtained tokens: ```go -client := client.NewClient(baseURL, - client.WithAuthProvider(client.NewStaticTokenProvider(token))) +client := v2.NewClient(baseURL, + v2.WithAuthProvider(v2.NewStaticTokenProvider(token))) ``` ## Error Handling ```go -app, err := client.ShowApp(ctx, appKey, region) +app, err := v2.ShowApp(ctx, appKey, region) if err != nil { // Check for specific error types - if errors.Is(err, client.ErrResourceNotFound) { + if errors.Is(err, v2.ErrResourceNotFound) { fmt.Println("App not found") return } // Check for API errors - var apiErr *client.APIError + var apiErr *v2.APIError if errors.As(err, &apiErr) { fmt.Printf("API Error %d: %s\n", apiErr.StatusCode, apiErr.Messages[0]) return @@ -213,13 +213,13 @@ The SDK provides a drop-in replacement with enhanced features: ```go // Old approach -oldClient := &client.EdgeConnect{ +oldClient := &v2.EdgeConnect{ BaseURL: baseURL, - Credentials: client.Credentials{Username: user, Password: pass}, + Credentials: v2.Credentials{Username: user, Password: pass}, } // New SDK approach -newClient := client.NewClientWithCredentials(baseURL, user, pass) +newClient := v2.NewClientWithCredentials(baseURL, user, pass) // Same method calls, enhanced reliability err := newClient.CreateApp(ctx, input) diff --git a/sdk/edgeconnect/appinstance.go b/sdk/edgeconnect/appinstance.go index ec4751a..a26f45c 100644 --- a/sdk/edgeconnect/appinstance.go +++ b/sdk/edgeconnect/appinstance.go @@ -4,11 +4,9 @@ package edgeconnect import ( - "bytes" "context" "encoding/json" "fmt" - "io" "net/http" sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" @@ -166,17 +164,18 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK return nil } -// DeleteAppInstance removes an application instance +// DeleteAppInstance removes an application instance from the specified region // Maps to POST /auth/ctrl/DeleteAppInst func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error { transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst" - input := DeleteAppInstanceInput{ - Key: appInstKey, + filter := AppInstanceFilter{ + AppInstance: AppInstance{Key: appInstKey}, + Region: region, } - resp, err := transport.Call(ctx, "POST", url, input) + resp, err := transport.Call(ctx, "POST", url, filter) if err != nil { return fmt.Errorf("DeleteAppInstance failed: %w", err) } @@ -195,29 +194,13 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe // parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error { - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - // Try parsing as a direct JSON array first (v2 API format) - switch v := result.(type) { - case *[]AppInstance: - var appInstances []AppInstance - if err := json.Unmarshal(bodyBytes, &appInstances); err == nil { - *v = appInstances - return nil - } - } - - // Fall back to streaming format (v1 API format) var appInstances []AppInstance var messages []string var hasError bool var errorCode int var errorMessage string - parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { + parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { // Try parsing as ResultResponse first (error format) var resultResp ResultResponse if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" { diff --git a/sdk/edgeconnect/apps.go b/sdk/edgeconnect/apps.go index 7010070..70f5dea 100644 --- a/sdk/edgeconnect/apps.go +++ b/sdk/edgeconnect/apps.go @@ -4,7 +4,6 @@ package edgeconnect import ( - "bytes" "context" "encoding/json" "fmt" @@ -143,12 +142,12 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er transport := c.getTransport() url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp" - input := DeleteAppInput{ - Key: appKey, + filter := AppFilter{ + App: App{Key: appKey}, Region: region, } - resp, err := transport.Call(ctx, "POST", url, input) + resp, err := transport.Call(ctx, "POST", url, filter) if err != nil { return fmt.Errorf("DeleteApp failed: %w", err) } @@ -167,27 +166,9 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er // parseStreamingResponse parses the EdgeXR streaming JSON response format func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error { - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - // Try parsing as a direct JSON array first (v2 API format) - switch v := result.(type) { - case *[]App: - var apps []App - if err := json.Unmarshal(bodyBytes, &apps); err == nil { - *v = apps - return nil - } - } - - // Fall back to streaming format (v1 API format) var responses []Response[App] - var apps []App - var messages []string - parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { + parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { var response Response[App] if err := json.Unmarshal(line, &response); err != nil { return err @@ -201,6 +182,9 @@ func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) } // Extract data from responses + var apps []App + var messages []string + for _, response := range responses { if response.HasData() { apps = append(apps, response.Data) diff --git a/sdk/edgeconnect/types.go b/sdk/edgeconnect/types.go index ffd5550..7fd39fc 100644 --- a/sdk/edgeconnect/types.go +++ b/sdk/edgeconnect/types.go @@ -184,33 +184,24 @@ type App struct { Deployment string `json:"deployment,omitempty"` ImageType string `json:"image_type,omitempty"` ImagePath string `json:"image_path,omitempty"` - AccessPorts string `json:"access_ports,omitempty"` AllowServerless bool `json:"allow_serverless,omitempty"` DefaultFlavor Flavor `json:"defaultFlavor,omitempty"` ServerlessConfig interface{} `json:"serverless_config,omitempty"` DeploymentGenerator string `json:"deployment_generator,omitempty"` DeploymentManifest string `json:"deployment_manifest,omitempty"` RequiredOutboundConnections []SecurityRule `json:"required_outbound_connections"` - GlobalID string `json:"global_id,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` Fields []string `json:"fields,omitempty"` } // AppInstance represents a deployed application instance type AppInstance struct { - msg `json:",inline"` - Key AppInstanceKey `json:"key"` - AppKey AppKey `json:"app_key,omitempty"` - CloudletLoc CloudletLoc `json:"cloudlet_loc,omitempty"` - Flavor Flavor `json:"flavor,omitempty"` - State string `json:"state,omitempty"` - IngressURL string `json:"ingress_url,omitempty"` - UniqueID string `json:"unique_id,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - PowerState string `json:"power_state,omitempty"` - Fields []string `json:"fields,omitempty"` + msg `json:",inline"` + Key AppInstanceKey `json:"key"` + AppKey AppKey `json:"app_key,omitempty"` + Flavor Flavor `json:"flavor,omitempty"` + State string `json:"state,omitempty"` + PowerState string `json:"power_state,omitempty"` + Fields []string `json:"fields,omitempty"` } // Cloudlet represents edge infrastructure @@ -233,12 +224,6 @@ type Location struct { Longitude float64 `json:"longitude"` } -// CloudletLoc represents geographical coordinates for cloudlets -type CloudletLoc struct { - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` -} - // Input types for API operations // NewAppInput represents input for creating an application @@ -271,17 +256,6 @@ type UpdateAppInstanceInput struct { AppInst AppInstance `json:"appinst"` } -// DeleteAppInput represents input for deleting an application -type DeleteAppInput struct { - Key AppKey `json:"key"` - Region string `json:"region"` -} - -// DeleteAppInstanceInput represents input for deleting an app instance -type DeleteAppInstanceInput struct { - Key AppInstanceKey `json:"key"` -} - // Response wrapper types // Response wraps a single API response diff --git a/sdk/edgeconnect/v2/appinstance.go b/sdk/edgeconnect/v2/appinstance.go new file mode 100644 index 0000000..57e6b3c --- /dev/null +++ b/sdk/edgeconnect/v2/appinstance.go @@ -0,0 +1,281 @@ +// ABOUTME: Application instance lifecycle management APIs for EdgeXR Master Controller +// ABOUTME: Provides typed methods for creating, querying, and deleting application instances + +package v2 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" +) + +// CreateAppInstance creates a new application instance in the specified region +// Maps to POST /auth/ctrl/CreateAppInst +func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInput) error { + + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/CreateAppInst" + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("CreateAppInstance failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.handleErrorResponse(resp, "CreateAppInstance") + } + + // Parse streaming JSON response + var appInstances []AppInstance + if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + return fmt.Errorf("ShowAppInstance failed to parse response: %w", err) + } + + c.logf("CreateAppInstance: %s/%s created successfully", + input.AppInst.Key.Organization, input.AppInst.Key.Name) + + return nil +} + +// ShowAppInstance retrieves a single application instance by key and region +// Maps to POST /auth/ctrl/ShowAppInst +func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) (AppInstance, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" + + filter := AppInstanceFilter{ + AppInstance: AppInstance{Key: appInstKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return AppInstance{}, fmt.Errorf("app instance %s/%s: %w", + appInstKey.Organization, appInstKey.Name, ErrResourceNotFound) + } + + if resp.StatusCode >= 400 { + return AppInstance{}, c.handleErrorResponse(resp, "ShowAppInstance") + } + + // Parse streaming JSON response + var appInstances []AppInstance + if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + return AppInstance{}, fmt.Errorf("ShowAppInstance failed to parse response: %w", err) + } + + if len(appInstances) == 0 { + return AppInstance{}, fmt.Errorf("app instance %s/%s in region %s: %w", + appInstKey.Organization, appInstKey.Name, region, ErrResourceNotFound) + } + + return appInstances[0], nil +} + +// ShowAppInstances retrieves all application instances matching the filter criteria +// Maps to POST /auth/ctrl/ShowAppInst +func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, region string) ([]AppInstance, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" + + filter := AppInstanceFilter{ + AppInstance: AppInstance{Key: appInstKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return nil, fmt.Errorf("ShowAppInstances failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { + return nil, c.handleErrorResponse(resp, "ShowAppInstances") + } + + var appInstances []AppInstance + if resp.StatusCode == http.StatusNotFound { + return appInstances, nil // Return empty slice for not found + } + + if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err) + } + + c.logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances)) + return appInstances, nil +} + +// UpdateAppInstance updates an application instance and then refreshes it +// Maps to POST /auth/ctrl/UpdateAppInst +func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstanceInput) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/UpdateAppInst" + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("UpdateAppInstance failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.handleErrorResponse(resp, "UpdateAppInstance") + } + + c.logf("UpdateAppInstance: %s/%s updated successfully", + input.AppInst.Key.Organization, input.AppInst.Key.Name) + + return nil +} + +// RefreshAppInstance refreshes an application instance's state +// Maps to POST /auth/ctrl/RefreshAppInst +func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/RefreshAppInst" + + filter := AppInstanceFilter{ + AppInstance: AppInstance{Key: appInstKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return fmt.Errorf("RefreshAppInstance failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.handleErrorResponse(resp, "RefreshAppInstance") + } + + c.logf("RefreshAppInstance: %s/%s refreshed successfully", + appInstKey.Organization, appInstKey.Name) + + return nil +} + +// DeleteAppInstance removes an application instance +// Maps to POST /auth/ctrl/DeleteAppInst +func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst" + + input := DeleteAppInstanceInput{ + Key: appInstKey, + } + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("DeleteAppInstance failed: %w", err) + } + defer resp.Body.Close() + + // 404 is acceptable for delete operations (already deleted) + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { + return c.handleErrorResponse(resp, "DeleteAppInstance") + } + + c.logf("DeleteAppInstance: %s/%s deleted successfully", + appInstKey.Organization, appInstKey.Name) + + return nil +} + +// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances +func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Try parsing as a direct JSON array first (v2 API format) + switch v := result.(type) { + case *[]AppInstance: + var appInstances []AppInstance + if err := json.Unmarshal(bodyBytes, &appInstances); err == nil { + *v = appInstances + return nil + } + } + + // Fall back to streaming format (v1 API format) + var appInstances []AppInstance + var messages []string + var hasError bool + var errorCode int + var errorMessage string + + parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { + // Try parsing as ResultResponse first (error format) + var resultResp ResultResponse + if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" { + if resultResp.IsError() { + hasError = true + errorCode = resultResp.GetCode() + errorMessage = resultResp.GetMessage() + } + return nil + } + + // Try parsing as Response[AppInstance] + var response Response[AppInstance] + if err := json.Unmarshal(line, &response); err != nil { + return err + } + + if response.HasData() { + appInstances = append(appInstances, response.Data) + } + if response.IsMessage() { + msg := response.Data.GetMessage() + messages = append(messages, msg) + // Check for error indicators in messages + if msg == "CreateError" || msg == "UpdateError" || msg == "DeleteError" { + hasError = true + } + } + return nil + }) + + if parseErr != nil { + return parseErr + } + + // If we detected an error, return it + if hasError { + apiErr := &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + } + if errorCode > 0 { + apiErr.StatusCode = errorCode + apiErr.Code = fmt.Sprintf("%d", errorCode) + } + if errorMessage != "" { + apiErr.Messages = append([]string{errorMessage}, apiErr.Messages...) + } + return apiErr + } + + // Set result based on type + switch v := result.(type) { + case *[]AppInstance: + *v = appInstances + default: + return fmt.Errorf("unsupported result type: %T", result) + } + + return nil +} diff --git a/sdk/edgeconnect/v2/appinstance_test.go b/sdk/edgeconnect/v2/appinstance_test.go new file mode 100644 index 0000000..e1c3d5e --- /dev/null +++ b/sdk/edgeconnect/v2/appinstance_test.go @@ -0,0 +1,524 @@ +// ABOUTME: Unit tests for AppInstance management APIs using httptest mock server +// ABOUTME: Tests create, show, list, refresh, and delete operations with error conditions + +package v2 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateAppInstance(t *testing.T) { + tests := []struct { + name string + input *NewAppInstanceInput + mockStatusCode int + mockResponse string + expectError bool + errorContains string + }{ + { + name: "successful creation", + input: &NewAppInstanceInput{ + Region: "us-west", + AppInst: AppInstance{ + Key: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + AppKey: AppKey{ + Organization: "testorg", + Name: "testapp", + Version: "1.0.0", + }, + Flavor: Flavor{Name: "m4.small"}, + }, + }, + mockStatusCode: 200, + mockResponse: `{"message": "success"}`, + expectError: false, + }, + { + name: "validation error", + input: &NewAppInstanceInput{ + Region: "us-west", + AppInst: AppInstance{ + Key: AppInstanceKey{ + Organization: "", + Name: "testinst", + }, + }, + }, + mockStatusCode: 400, + mockResponse: `{"message": "organization is required"}`, + expectError: true, + }, + { + name: "HTTP 200 with CreateError message", + input: &NewAppInstanceInput{ + Region: "us-west", + AppInst: AppInstance{ + Key: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + Flavor: Flavor{Name: "m4.small"}, + }, + }, + mockStatusCode: 200, + mockResponse: `{"data":{"message":"Creating"}} +{"data":{"message":"a service has been configured"}} +{"data":{"message":"CreateError"}} +{"data":{"message":"Deleting AppInst due to failure"}} +{"data":{"message":"Deleted AppInst successfully"}}`, + expectError: true, + errorContains: "CreateError", + }, + { + name: "HTTP 200 with result error code", + input: &NewAppInstanceInput{ + Region: "us-west", + AppInst: AppInstance{ + Key: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + Flavor: Flavor{Name: "m4.small"}, + }, + }, + mockStatusCode: 200, + mockResponse: `{"data":{"message":"Creating"}} +{"data":{"message":"a service has been configured"}} +{"data":{"message":"CreateError"}} +{"data":{"message":"Deleting AppInst due to failure"}} +{"data":{"message":"Deleted AppInst successfully"}} +{"result":{"message":"Encountered failures: Create App Inst failed: deployments.apps is forbidden: User \"system:serviceaccount:edgexr:crm-telekomop-munich\" cannot create resource \"deployments\" in API group \"apps\" in the namespace \"gitea\"","code":400}}`, + expectError: true, + errorContains: "deployments.apps is forbidden", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/CreateAppInst", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + w.WriteHeader(tt.mockStatusCode) + w.Write([]byte(tt.mockResponse)) + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, + WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), + WithAuthProvider(NewStaticTokenProvider("test-token")), + ) + + // Execute test + ctx := context.Background() + err := client.CreateAppInstance(ctx, tt.input) + + // Verify results + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestShowAppInstance(t *testing.T) { + tests := []struct { + name string + appInstKey AppInstanceKey + region string + mockStatusCode int + mockResponse string + expectError bool + expectNotFound bool + }{ + { + name: "successful show", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 200, + mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}} +`, + expectError: false, + expectNotFound: false, + }, + { + name: "instance not found", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "nonexistent", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 404, + mockResponse: "", + expectError: true, + expectNotFound: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path) + + w.WriteHeader(tt.mockStatusCode) + if tt.mockResponse != "" { + w.Write([]byte(tt.mockResponse)) + } + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, + WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), + ) + + // Execute test + ctx := context.Background() + appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region) + + // Verify results + if tt.expectError { + assert.Error(t, err) + if tt.expectNotFound { + assert.Contains(t, err.Error(), "resource not found") + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.appInstKey.Organization, appInst.Key.Organization) + assert.Equal(t, tt.appInstKey.Name, appInst.Key.Name) + assert.Equal(t, "Ready", appInst.State) + } + }) + } +} + +func TestShowAppInstances(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path) + + // Verify request body + var filter AppInstanceFilter + err := json.NewDecoder(r.Body).Decode(&filter) + require.NoError(t, err) + assert.Equal(t, "testorg", filter.AppInstance.Key.Organization) + assert.Equal(t, "us-west", filter.Region) + + // Return multiple app instances + response := `{"data": {"key": {"organization": "testorg", "name": "inst1"}, "state": "Ready"}} +{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}} +` + w.WriteHeader(200) + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL) + ctx := context.Background() + + appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, "us-west") + + require.NoError(t, err) + assert.Len(t, appInstances, 2) + assert.Equal(t, "inst1", appInstances[0].Key.Name) + assert.Equal(t, "Ready", appInstances[0].State) + assert.Equal(t, "inst2", appInstances[1].Key.Name) + assert.Equal(t, "Creating", appInstances[1].State) +} + +func TestUpdateAppInstance(t *testing.T) { + tests := []struct { + name string + input *UpdateAppInstanceInput + mockStatusCode int + mockResponse string + expectError bool + }{ + { + name: "successful update", + input: &UpdateAppInstanceInput{ + Region: "us-west", + AppInst: AppInstance{ + Key: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + AppKey: AppKey{ + Organization: "testorg", + Name: "testapp", + Version: "1.0.0", + }, + Flavor: Flavor{Name: "m4.medium"}, + PowerState: "PowerOn", + }, + }, + mockStatusCode: 200, + mockResponse: `{"message": "success"}`, + expectError: false, + }, + { + name: "validation error", + input: &UpdateAppInstanceInput{ + Region: "us-west", + AppInst: AppInstance{ + Key: AppInstanceKey{ + Organization: "", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + }, + }, + mockStatusCode: 400, + mockResponse: `{"message": "organization is required"}`, + expectError: true, + }, + { + name: "instance not found", + input: &UpdateAppInstanceInput{ + Region: "us-west", + AppInst: AppInstance{ + Key: AppInstanceKey{ + Organization: "testorg", + Name: "nonexistent", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + }, + }, + mockStatusCode: 404, + mockResponse: `{"message": "app instance not found"}`, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/UpdateAppInst", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + // Verify request body + var input UpdateAppInstanceInput + err := json.NewDecoder(r.Body).Decode(&input) + require.NoError(t, err) + assert.Equal(t, tt.input.Region, input.Region) + assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization) + + w.WriteHeader(tt.mockStatusCode) + w.Write([]byte(tt.mockResponse)) + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, + WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), + WithAuthProvider(NewStaticTokenProvider("test-token")), + ) + + // Execute test + ctx := context.Background() + err := client.UpdateAppInstance(ctx, tt.input) + + // Verify results + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestRefreshAppInstance(t *testing.T) { + tests := []struct { + name string + appInstKey AppInstanceKey + region string + mockStatusCode int + expectError bool + }{ + { + name: "successful refresh", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 200, + expectError: false, + }, + { + name: "server error", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 500, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/RefreshAppInst", r.URL.Path) + + w.WriteHeader(tt.mockStatusCode) + })) + defer server.Close() + + client := NewClient(server.URL) + ctx := context.Background() + + err := client.RefreshAppInstance(ctx, tt.appInstKey, tt.region) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestDeleteAppInstance(t *testing.T) { + tests := []struct { + name string + appInstKey AppInstanceKey + region string + mockStatusCode int + expectError bool + }{ + { + name: "successful deletion", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 200, + expectError: false, + }, + { + name: "already deleted (404 ok)", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 404, + expectError: false, + }, + { + name: "server error", + appInstKey: AppInstanceKey{ + Organization: "testorg", + Name: "testinst", + CloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + }, + region: "us-west", + mockStatusCode: 500, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/DeleteAppInst", r.URL.Path) + + w.WriteHeader(tt.mockStatusCode) + })) + defer server.Close() + + client := NewClient(server.URL) + ctx := context.Background() + + err := client.DeleteAppInstance(ctx, tt.appInstKey, tt.region) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/sdk/edgeconnect/v2/apps.go b/sdk/edgeconnect/v2/apps.go new file mode 100644 index 0000000..ce5bb76 --- /dev/null +++ b/sdk/edgeconnect/v2/apps.go @@ -0,0 +1,267 @@ +// ABOUTME: Application lifecycle management APIs for EdgeXR Master Controller +// ABOUTME: Provides typed methods for creating, querying, and deleting applications + +package v2 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" +) + +var ( + // ErrResourceNotFound indicates the requested resource was not found + ErrResourceNotFound = fmt.Errorf("resource not found") +) + +// CreateApp creates a new application in the specified region +// Maps to POST /auth/ctrl/CreateApp +func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/CreateApp" + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("CreateApp failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.handleErrorResponse(resp, "CreateApp") + } + + c.logf("CreateApp: %s/%s version %s created successfully", + input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version) + + return nil +} + +// ShowApp retrieves a single application by key and region +// Maps to POST /auth/ctrl/ShowApp +func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp" + + filter := AppFilter{ + App: App{Key: appKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return App{}, fmt.Errorf("ShowApp failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", + appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound) + } + + if resp.StatusCode >= 400 { + return App{}, c.handleErrorResponse(resp, "ShowApp") + } + + // Parse streaming JSON response + var apps []App + if err := c.parseStreamingResponse(resp, &apps); err != nil { + return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err) + } + + if len(apps) == 0 { + return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", + appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound) + } + + return apps[0], nil +} + +// ShowApps retrieves all applications matching the filter criteria +// Maps to POST /auth/ctrl/ShowApp +func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]App, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp" + + filter := AppFilter{ + App: App{Key: appKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return nil, fmt.Errorf("ShowApps failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { + return nil, c.handleErrorResponse(resp, "ShowApps") + } + + var apps []App + if resp.StatusCode == http.StatusNotFound { + return apps, nil // Return empty slice for not found + } + + if err := c.parseStreamingResponse(resp, &apps); err != nil { + return nil, fmt.Errorf("ShowApps failed to parse response: %w", err) + } + + c.logf("ShowApps: found %d apps matching criteria", len(apps)) + return apps, nil +} + +// UpdateApp updates the definition of an application +// Maps to POST /auth/ctrl/UpdateApp +func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/UpdateApp" + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("UpdateApp failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.handleErrorResponse(resp, "UpdateApp") + } + + c.logf("UpdateApp: %s/%s version %s updated successfully", + input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version) + + return nil +} + +// DeleteApp removes an application from the specified region +// Maps to POST /auth/ctrl/DeleteApp +func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp" + + input := DeleteAppInput{ + Key: appKey, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("DeleteApp failed: %w", err) + } + defer resp.Body.Close() + + // 404 is acceptable for delete operations (already deleted) + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { + return c.handleErrorResponse(resp, "DeleteApp") + } + + c.logf("DeleteApp: %s/%s version %s deleted successfully", + appKey.Organization, appKey.Name, appKey.Version) + + return nil +} + +// parseStreamingResponse parses the EdgeXR streaming JSON response format +func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Try parsing as a direct JSON array first (v2 API format) + switch v := result.(type) { + case *[]App: + var apps []App + if err := json.Unmarshal(bodyBytes, &apps); err == nil { + *v = apps + return nil + } + } + + // Fall back to streaming format (v1 API format) + var responses []Response[App] + var apps []App + var messages []string + + parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error { + var response Response[App] + if err := json.Unmarshal(line, &response); err != nil { + return err + } + responses = append(responses, response) + return nil + }) + + if parseErr != nil { + return parseErr + } + + // Extract data from responses + for _, response := range responses { + if response.HasData() { + apps = append(apps, response.Data) + } + if response.IsMessage() { + messages = append(messages, response.Data.GetMessage()) + } + } + + // If we have error messages, return them + if len(messages) > 0 { + return &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + } + } + + // Set result based on type + switch v := result.(type) { + case *[]App: + *v = apps + default: + return fmt.Errorf("unsupported result type: %T", result) + } + + return nil +} + +// getTransport creates an HTTP transport with current client settings +func (c *Client) getTransport() *sdkhttp.Transport { + return sdkhttp.NewTransport( + sdkhttp.RetryOptions{ + MaxRetries: c.RetryOpts.MaxRetries, + InitialDelay: c.RetryOpts.InitialDelay, + MaxDelay: c.RetryOpts.MaxDelay, + Multiplier: c.RetryOpts.Multiplier, + RetryableHTTPStatusCodes: c.RetryOpts.RetryableHTTPStatusCodes, + }, + c.AuthProvider, + c.Logger, + ) +} + +// handleErrorResponse creates an appropriate error from HTTP error response +func (c *Client) handleErrorResponse(resp *http.Response, operation string) error { + + messages := []string{ + fmt.Sprintf("%s failed with status %d", operation, resp.StatusCode), + } + + bodyBytes := []byte{} + + if resp.Body != nil { + defer resp.Body.Close() + bodyBytes, _ = io.ReadAll(resp.Body) + messages = append(messages, string(bodyBytes)) + } + + return &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + Body: bodyBytes, + } +} diff --git a/sdk/edgeconnect/v2/apps_test.go b/sdk/edgeconnect/v2/apps_test.go new file mode 100644 index 0000000..4ea757c --- /dev/null +++ b/sdk/edgeconnect/v2/apps_test.go @@ -0,0 +1,419 @@ +// ABOUTME: Unit tests for App management APIs using httptest mock server +// ABOUTME: Tests create, show, list, and delete operations with error conditions + +package v2 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateApp(t *testing.T) { + tests := []struct { + name string + input *NewAppInput + mockStatusCode int + mockResponse string + expectError bool + }{ + { + name: "successful creation", + input: &NewAppInput{ + Region: "us-west", + App: App{ + Key: AppKey{ + Organization: "testorg", + Name: "testapp", + Version: "1.0.0", + }, + Deployment: "kubernetes", + }, + }, + mockStatusCode: 200, + mockResponse: `{"message": "success"}`, + expectError: false, + }, + { + name: "validation error", + input: &NewAppInput{ + Region: "us-west", + App: App{ + Key: AppKey{ + Organization: "", + Name: "testapp", + Version: "1.0.0", + }, + }, + }, + mockStatusCode: 400, + mockResponse: `{"message": "organization is required"}`, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/CreateApp", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + w.WriteHeader(tt.mockStatusCode) + w.Write([]byte(tt.mockResponse)) + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, + WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), + WithAuthProvider(NewStaticTokenProvider("test-token")), + ) + + // Execute test + ctx := context.Background() + err := client.CreateApp(ctx, tt.input) + + // Verify results + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestShowApp(t *testing.T) { + tests := []struct { + name string + appKey AppKey + region string + mockStatusCode int + mockResponse string + expectError bool + expectNotFound bool + }{ + { + name: "successful show", + appKey: AppKey{ + Organization: "testorg", + Name: "testapp", + Version: "1.0.0", + }, + region: "us-west", + mockStatusCode: 200, + mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testapp", "version": "1.0.0"}, "deployment": "kubernetes"}} +`, + expectError: false, + expectNotFound: false, + }, + { + name: "app not found", + appKey: AppKey{ + Organization: "testorg", + Name: "nonexistent", + Version: "1.0.0", + }, + region: "us-west", + mockStatusCode: 404, + mockResponse: "", + expectError: true, + expectNotFound: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path) + + w.WriteHeader(tt.mockStatusCode) + if tt.mockResponse != "" { + w.Write([]byte(tt.mockResponse)) + } + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, + WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), + ) + + // Execute test + ctx := context.Background() + app, err := client.ShowApp(ctx, tt.appKey, tt.region) + + // Verify results + if tt.expectError { + assert.Error(t, err) + if tt.expectNotFound { + assert.Contains(t, err.Error(), "resource not found") + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.appKey.Organization, app.Key.Organization) + assert.Equal(t, tt.appKey.Name, app.Key.Name) + assert.Equal(t, tt.appKey.Version, app.Key.Version) + } + }) + } +} + +func TestShowApps(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path) + + // Verify request body + var filter AppFilter + err := json.NewDecoder(r.Body).Decode(&filter) + require.NoError(t, err) + assert.Equal(t, "testorg", filter.App.Key.Organization) + assert.Equal(t, "us-west", filter.Region) + + // Return multiple apps + response := `{"data": {"key": {"organization": "testorg", "name": "app1", "version": "1.0.0"}, "deployment": "kubernetes"}} +{"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}} +` + w.WriteHeader(200) + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL) + ctx := context.Background() + + apps, err := client.ShowApps(ctx, AppKey{Organization: "testorg"}, "us-west") + + require.NoError(t, err) + assert.Len(t, apps, 2) + assert.Equal(t, "app1", apps[0].Key.Name) + assert.Equal(t, "app2", apps[1].Key.Name) +} + +func TestUpdateApp(t *testing.T) { + tests := []struct { + name string + input *UpdateAppInput + mockStatusCode int + mockResponse string + expectError bool + }{ + { + name: "successful update", + input: &UpdateAppInput{ + Region: "us-west", + App: App{ + Key: AppKey{ + Organization: "testorg", + Name: "testapp", + Version: "1.0.0", + }, + Deployment: "kubernetes", + ImagePath: "nginx:latest", + }, + }, + mockStatusCode: 200, + mockResponse: `{"message": "success"}`, + expectError: false, + }, + { + name: "validation error", + input: &UpdateAppInput{ + Region: "us-west", + App: App{ + Key: AppKey{ + Organization: "", + Name: "testapp", + Version: "1.0.0", + }, + }, + }, + mockStatusCode: 400, + mockResponse: `{"message": "organization is required"}`, + expectError: true, + }, + { + name: "app not found", + input: &UpdateAppInput{ + Region: "us-west", + App: App{ + Key: AppKey{ + Organization: "testorg", + Name: "nonexistent", + Version: "1.0.0", + }, + }, + }, + mockStatusCode: 404, + mockResponse: `{"message": "app not found"}`, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/UpdateApp", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + // Verify request body + var input UpdateAppInput + err := json.NewDecoder(r.Body).Decode(&input) + require.NoError(t, err) + assert.Equal(t, tt.input.Region, input.Region) + assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization) + + w.WriteHeader(tt.mockStatusCode) + w.Write([]byte(tt.mockResponse)) + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, + WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), + WithAuthProvider(NewStaticTokenProvider("test-token")), + ) + + // Execute test + ctx := context.Background() + err := client.UpdateApp(ctx, tt.input) + + // Verify results + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestDeleteApp(t *testing.T) { + tests := []struct { + name string + appKey AppKey + region string + mockStatusCode int + expectError bool + }{ + { + name: "successful deletion", + appKey: AppKey{ + Organization: "testorg", + Name: "testapp", + Version: "1.0.0", + }, + region: "us-west", + mockStatusCode: 200, + expectError: false, + }, + { + name: "already deleted (404 ok)", + appKey: AppKey{ + Organization: "testorg", + Name: "testapp", + Version: "1.0.0", + }, + region: "us-west", + mockStatusCode: 404, + expectError: false, + }, + { + name: "server error", + appKey: AppKey{ + Organization: "testorg", + Name: "testapp", + Version: "1.0.0", + }, + region: "us-west", + mockStatusCode: 500, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/DeleteApp", r.URL.Path) + + w.WriteHeader(tt.mockStatusCode) + })) + defer server.Close() + + client := NewClient(server.URL) + ctx := context.Background() + + err := client.DeleteApp(ctx, tt.appKey, tt.region) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestClientOptions(t *testing.T) { + t.Run("with auth provider", func(t *testing.T) { + authProvider := NewStaticTokenProvider("test-token") + client := NewClient("https://example.com", + WithAuthProvider(authProvider), + ) + + assert.Equal(t, authProvider, client.AuthProvider) + }) + + t.Run("with custom HTTP client", func(t *testing.T) { + httpClient := &http.Client{Timeout: 10 * time.Second} + client := NewClient("https://example.com", + WithHTTPClient(httpClient), + ) + + assert.Equal(t, httpClient, client.HTTPClient) + }) + + t.Run("with retry options", func(t *testing.T) { + retryOpts := RetryOptions{MaxRetries: 5} + client := NewClient("https://example.com", + WithRetryOptions(retryOpts), + ) + + assert.Equal(t, 5, client.RetryOpts.MaxRetries) + }) +} + +func TestAPIError(t *testing.T) { + err := &APIError{ + StatusCode: 400, + Messages: []string{"validation failed", "name is required"}, + } + + assert.Contains(t, err.Error(), "validation failed") + assert.Equal(t, 400, err.StatusCode) + assert.Len(t, err.Messages, 2) +} + +// Helper function to create a test server that handles streaming JSON responses +func createStreamingJSONServer(responses []string, statusCode int) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(statusCode) + for _, response := range responses { + w.Write([]byte(response + "\n")) + } + })) +} diff --git a/sdk/edgeconnect/v2/auth.go b/sdk/edgeconnect/v2/auth.go new file mode 100644 index 0000000..a1f33a2 --- /dev/null +++ b/sdk/edgeconnect/v2/auth.go @@ -0,0 +1,184 @@ +// ABOUTME: Authentication providers for EdgeXR Master Controller API +// ABOUTME: Supports Bearer token authentication with pluggable provider interface + +package v2 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// AuthProvider interface for attaching authentication to requests +type AuthProvider interface { + // Attach adds authentication headers to the request + Attach(ctx context.Context, req *http.Request) error +} + +// StaticTokenProvider implements Bearer token authentication with a fixed token +type StaticTokenProvider struct { + Token string +} + +// NewStaticTokenProvider creates a new static token provider +func NewStaticTokenProvider(token string) *StaticTokenProvider { + return &StaticTokenProvider{Token: token} +} + +// Attach adds the Bearer token to the request Authorization header +func (s *StaticTokenProvider) Attach(ctx context.Context, req *http.Request) error { + if s.Token != "" { + req.Header.Set("Authorization", "Bearer "+s.Token) + } + return nil +} + +// UsernamePasswordProvider implements dynamic token retrieval using username/password +// This matches the existing client/client.go RetrieveToken implementation +type UsernamePasswordProvider struct { + BaseURL string + Username string + Password string + HTTPClient *http.Client + + // Token caching + mu sync.RWMutex + cachedToken string + tokenExpiry time.Time +} + +// NewUsernamePasswordProvider creates a new username/password auth provider +func NewUsernamePasswordProvider(baseURL, username, password string, httpClient *http.Client) *UsernamePasswordProvider { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + + return &UsernamePasswordProvider{ + BaseURL: strings.TrimRight(baseURL, "/"), + Username: username, + Password: password, + HTTPClient: httpClient, + } +} + +// Attach retrieves a token (with caching) and adds it to the Authorization header +func (u *UsernamePasswordProvider) Attach(ctx context.Context, req *http.Request) error { + token, err := u.getToken(ctx) + if err != nil { + return fmt.Errorf("failed to get token: %w", err) + } + + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + return nil +} + +// getToken retrieves a token, using cache if valid +func (u *UsernamePasswordProvider) getToken(ctx context.Context) (string, error) { + // Check cache first + u.mu.RLock() + if u.cachedToken != "" && time.Now().Before(u.tokenExpiry) { + token := u.cachedToken + u.mu.RUnlock() + return token, nil + } + u.mu.RUnlock() + + // Need to retrieve new token + u.mu.Lock() + defer u.mu.Unlock() + + // Double-check after acquiring write lock + if u.cachedToken != "" && time.Now().Before(u.tokenExpiry) { + return u.cachedToken, nil + } + + // Retrieve token using existing RetrieveToken logic + token, err := u.retrieveToken(ctx) + if err != nil { + return "", err + } + + // Cache token with reasonable expiry (assume 1 hour, can be configurable) + u.cachedToken = token + u.tokenExpiry = time.Now().Add(1 * time.Hour) + + return token, nil +} + +// retrieveToken implements the same logic as the existing client/client.go RetrieveToken method +func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, error) { + // Marshal credentials - same as existing implementation + jsonData, err := json.Marshal(map[string]string{ + "username": u.Username, + "password": u.Password, + }) + if err != nil { + return "", err + } + + // Create request - same as existing implementation + loginURL := u.BaseURL + "/api/v1/login" + request, err := http.NewRequestWithContext(ctx, "POST", loginURL, bytes.NewBuffer(jsonData)) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/json") + + // Execute request + resp, err := u.HTTPClient.Do(request) + if err != nil { + return "", err + } + defer resp.Body.Close() + + // Read response body - same as existing implementation + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(body)) + } + + // Parse JSON response - same as existing implementation + var respData struct { + Token string `json:"token"` + } + err = json.Unmarshal(body, &respData) + if err != nil { + return "", fmt.Errorf("error parsing JSON (status %d): %v", resp.StatusCode, err) + } + + return respData.Token, nil +} + +// InvalidateToken clears the cached token, forcing a new login on next request +func (u *UsernamePasswordProvider) InvalidateToken() { + u.mu.Lock() + defer u.mu.Unlock() + u.cachedToken = "" + u.tokenExpiry = time.Time{} +} + +// NoAuthProvider implements no authentication (for testing or public endpoints) +type NoAuthProvider struct{} + +// NewNoAuthProvider creates a new no-auth provider +func NewNoAuthProvider() *NoAuthProvider { + return &NoAuthProvider{} +} + +// Attach does nothing (no authentication) +func (n *NoAuthProvider) Attach(ctx context.Context, req *http.Request) error { + return nil +} diff --git a/sdk/edgeconnect/v2/auth_test.go b/sdk/edgeconnect/v2/auth_test.go new file mode 100644 index 0000000..0fc5b24 --- /dev/null +++ b/sdk/edgeconnect/v2/auth_test.go @@ -0,0 +1,226 @@ +// ABOUTME: Unit tests for authentication providers including username/password token flow +// ABOUTME: Tests token caching, login flow, and error conditions with mock servers + +package v2 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStaticTokenProvider(t *testing.T) { + provider := NewStaticTokenProvider("test-token-123") + + req, _ := http.NewRequest("GET", "https://example.com", nil) + ctx := context.Background() + + err := provider.Attach(ctx, req) + + require.NoError(t, err) + assert.Equal(t, "Bearer test-token-123", req.Header.Get("Authorization")) +} + +func TestStaticTokenProvider_EmptyToken(t *testing.T) { + provider := NewStaticTokenProvider("") + + req, _ := http.NewRequest("GET", "https://example.com", nil) + ctx := context.Background() + + err := provider.Attach(ctx, req) + + require.NoError(t, err) + assert.Empty(t, req.Header.Get("Authorization")) +} + +func TestUsernamePasswordProvider_Success(t *testing.T) { + // Mock login server + loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/login", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + // Verify request body + var creds map[string]string + err := json.NewDecoder(r.Body).Decode(&creds) + require.NoError(t, err) + assert.Equal(t, "testuser", creds["username"]) + assert.Equal(t, "testpass", creds["password"]) + + // Return token + response := map[string]string{"token": "dynamic-token-456"} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + defer loginServer.Close() + + provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil) + + req, _ := http.NewRequest("GET", "https://api.example.com", nil) + ctx := context.Background() + + err := provider.Attach(ctx, req) + + require.NoError(t, err) + assert.Equal(t, "Bearer dynamic-token-456", req.Header.Get("Authorization")) +} + +func TestUsernamePasswordProvider_LoginFailure(t *testing.T) { + // Mock login server that returns error + loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Invalid credentials")) + })) + defer loginServer.Close() + + provider := NewUsernamePasswordProvider(loginServer.URL, "baduser", "badpass", nil) + + req, _ := http.NewRequest("GET", "https://api.example.com", nil) + ctx := context.Background() + + err := provider.Attach(ctx, req) + + require.Error(t, err) + assert.Contains(t, err.Error(), "login failed with status 401") + assert.Contains(t, err.Error(), "Invalid credentials") +} + +func TestUsernamePasswordProvider_TokenCaching(t *testing.T) { + callCount := 0 + + // Mock login server that tracks calls + loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + response := map[string]string{"token": "cached-token-789"} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + defer loginServer.Close() + + provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil) + ctx := context.Background() + + // First request should call login + req1, _ := http.NewRequest("GET", "https://api.example.com", nil) + err1 := provider.Attach(ctx, req1) + require.NoError(t, err1) + assert.Equal(t, "Bearer cached-token-789", req1.Header.Get("Authorization")) + assert.Equal(t, 1, callCount) + + // Second request should use cached token (no additional login call) + req2, _ := http.NewRequest("GET", "https://api.example.com", nil) + err2 := provider.Attach(ctx, req2) + require.NoError(t, err2) + assert.Equal(t, "Bearer cached-token-789", req2.Header.Get("Authorization")) + assert.Equal(t, 1, callCount) // Still only 1 call +} + +func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) { + callCount := 0 + + loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + response := map[string]string{"token": "refreshed-token-999"} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + defer loginServer.Close() + + provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil) + + // Manually set expired token + provider.mu.Lock() + provider.cachedToken = "expired-token" + provider.tokenExpiry = time.Now().Add(-1 * time.Hour) // Already expired + provider.mu.Unlock() + + ctx := context.Background() + req, _ := http.NewRequest("GET", "https://api.example.com", nil) + + err := provider.Attach(ctx, req) + + require.NoError(t, err) + assert.Equal(t, "Bearer refreshed-token-999", req.Header.Get("Authorization")) + assert.Equal(t, 1, callCount) // New token retrieved +} + +func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) { + callCount := 0 + + loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + response := map[string]string{"token": "new-token-after-invalidation"} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + defer loginServer.Close() + + provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil) + ctx := context.Background() + + // First request to get token + req1, _ := http.NewRequest("GET", "https://api.example.com", nil) + err1 := provider.Attach(ctx, req1) + require.NoError(t, err1) + assert.Equal(t, 1, callCount) + + // Invalidate token + provider.InvalidateToken() + + // Next request should get new token + req2, _ := http.NewRequest("GET", "https://api.example.com", nil) + err2 := provider.Attach(ctx, req2) + require.NoError(t, err2) + assert.Equal(t, "Bearer new-token-after-invalidation", req2.Header.Get("Authorization")) + assert.Equal(t, 2, callCount) // New login call made +} + +func TestUsernamePasswordProvider_BadJSONResponse(t *testing.T) { + // Mock server returning invalid JSON + loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("invalid json response")) + })) + defer loginServer.Close() + + provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil) + + req, _ := http.NewRequest("GET", "https://api.example.com", nil) + ctx := context.Background() + + err := provider.Attach(ctx, req) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error parsing JSON") +} + +func TestNoAuthProvider(t *testing.T) { + provider := NewNoAuthProvider() + + req, _ := http.NewRequest("GET", "https://example.com", nil) + ctx := context.Background() + + err := provider.Attach(ctx, req) + + require.NoError(t, err) + assert.Empty(t, req.Header.Get("Authorization")) +} + +func TestNewClientWithCredentials(t *testing.T) { + client := NewClientWithCredentials("https://example.com", "testuser", "testpass") + + assert.Equal(t, "https://example.com", client.BaseURL) + + // Check that auth provider is UsernamePasswordProvider + authProvider, ok := client.AuthProvider.(*UsernamePasswordProvider) + require.True(t, ok, "AuthProvider should be UsernamePasswordProvider") + assert.Equal(t, "testuser", authProvider.Username) + assert.Equal(t, "testpass", authProvider.Password) + assert.Equal(t, "https://example.com", authProvider.BaseURL) +} diff --git a/sdk/edgeconnect/v2/client.go b/sdk/edgeconnect/v2/client.go new file mode 100644 index 0000000..6846b83 --- /dev/null +++ b/sdk/edgeconnect/v2/client.go @@ -0,0 +1,122 @@ +// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth +// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations + +package v2 + +import ( + "net/http" + "strings" + "time" +) + +// Client represents the EdgeXR Master Controller SDK client +type Client struct { + BaseURL string + HTTPClient *http.Client + AuthProvider AuthProvider + RetryOpts RetryOptions + Logger Logger +} + +// RetryOptions configures retry behavior for API calls +type RetryOptions struct { + MaxRetries int + InitialDelay time.Duration + MaxDelay time.Duration + Multiplier float64 + RetryableHTTPStatusCodes []int +} + +// Logger interface for optional logging +type Logger interface { + Printf(format string, v ...interface{}) +} + +// DefaultRetryOptions returns sensible default retry configuration +func DefaultRetryOptions() RetryOptions { + return RetryOptions{ + MaxRetries: 3, + InitialDelay: 1 * time.Second, + MaxDelay: 30 * time.Second, + Multiplier: 2.0, + RetryableHTTPStatusCodes: []int{ + http.StatusRequestTimeout, + http.StatusTooManyRequests, + http.StatusInternalServerError, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout, + }, + } +} + +// Option represents a configuration option for the client +type Option func(*Client) + +// WithHTTPClient sets a custom HTTP client +func WithHTTPClient(client *http.Client) Option { + return func(c *Client) { + c.HTTPClient = client + } +} + +// WithAuthProvider sets the authentication provider +func WithAuthProvider(auth AuthProvider) Option { + return func(c *Client) { + c.AuthProvider = auth + } +} + +// WithRetryOptions sets retry configuration +func WithRetryOptions(opts RetryOptions) Option { + return func(c *Client) { + c.RetryOpts = opts + } +} + +// WithLogger sets a logger for debugging +func WithLogger(logger Logger) Option { + return func(c *Client) { + c.Logger = logger + } +} + +// NewClient creates a new EdgeXR SDK client +func NewClient(baseURL string, options ...Option) *Client { + client := &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + AuthProvider: NewNoAuthProvider(), + RetryOpts: DefaultRetryOptions(), + } + + for _, opt := range options { + opt(client) + } + + return client +} + +// NewClientWithCredentials creates a new EdgeXR SDK client with username/password authentication +// This matches the existing client pattern from client/client.go +func NewClientWithCredentials(baseURL, username, password string, options ...Option) *Client { + client := &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + AuthProvider: NewUsernamePasswordProvider(baseURL, username, password, nil), + RetryOpts: DefaultRetryOptions(), + } + + for _, opt := range options { + opt(client) + } + + return client +} + +// logf logs a message if a logger is configured +func (c *Client) logf(format string, v ...interface{}) { + if c.Logger != nil { + c.Logger.Printf(format, v...) + } +} diff --git a/sdk/edgeconnect/v2/cloudlet.go b/sdk/edgeconnect/v2/cloudlet.go new file mode 100644 index 0000000..85ef522 --- /dev/null +++ b/sdk/edgeconnect/v2/cloudlet.go @@ -0,0 +1,271 @@ +// ABOUTME: Cloudlet management APIs for EdgeXR Master Controller +// ABOUTME: Provides typed methods for creating, querying, and managing edge cloudlets + +package v2 + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http" +) + +// CreateCloudlet creates a new cloudlet in the specified region +// Maps to POST /auth/ctrl/CreateCloudlet +func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/CreateCloudlet" + + resp, err := transport.Call(ctx, "POST", url, input) + if err != nil { + return fmt.Errorf("CreateCloudlet failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.handleErrorResponse(resp, "CreateCloudlet") + } + + c.logf("CreateCloudlet: %s/%s created successfully", + input.Cloudlet.Key.Organization, input.Cloudlet.Key.Name) + + return nil +} + +// ShowCloudlet retrieves a single cloudlet by key and region +// Maps to POST /auth/ctrl/ShowCloudlet +func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) (Cloudlet, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet" + + filter := CloudletFilter{ + Cloudlet: Cloudlet{Key: cloudletKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", + cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) + } + + if resp.StatusCode >= 400 { + return Cloudlet{}, c.handleErrorResponse(resp, "ShowCloudlet") + } + + // Parse streaming JSON response + var cloudlets []Cloudlet + if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil { + return Cloudlet{}, fmt.Errorf("ShowCloudlet failed to parse response: %w", err) + } + + if len(cloudlets) == 0 { + return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", + cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) + } + + return cloudlets[0], nil +} + +// ShowCloudlets retrieves all cloudlets matching the filter criteria +// Maps to POST /auth/ctrl/ShowCloudlet +func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, region string) ([]Cloudlet, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet" + + filter := CloudletFilter{ + Cloudlet: Cloudlet{Key: cloudletKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return nil, fmt.Errorf("ShowCloudlets failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { + return nil, c.handleErrorResponse(resp, "ShowCloudlets") + } + + var cloudlets []Cloudlet + if resp.StatusCode == http.StatusNotFound { + return cloudlets, nil // Return empty slice for not found + } + + if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil { + return nil, fmt.Errorf("ShowCloudlets failed to parse response: %w", err) + } + + c.logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets)) + return cloudlets, nil +} + +// DeleteCloudlet removes a cloudlet from the specified region +// Maps to POST /auth/ctrl/DeleteCloudlet +func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) error { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/DeleteCloudlet" + + filter := CloudletFilter{ + Cloudlet: Cloudlet{Key: cloudletKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return fmt.Errorf("DeleteCloudlet failed: %w", err) + } + defer resp.Body.Close() + + // 404 is acceptable for delete operations (already deleted) + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { + return c.handleErrorResponse(resp, "DeleteCloudlet") + } + + c.logf("DeleteCloudlet: %s/%s deleted successfully", + cloudletKey.Organization, cloudletKey.Name) + + return nil +} + +// GetCloudletManifest retrieves the deployment manifest for a cloudlet +// Maps to POST /auth/ctrl/GetCloudletManifest +func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletManifest, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletManifest" + + filter := CloudletFilter{ + Cloudlet: Cloudlet{Key: cloudletKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return nil, fmt.Errorf("GetCloudletManifest failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w", + cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) + } + + if resp.StatusCode >= 400 { + return nil, c.handleErrorResponse(resp, "GetCloudletManifest") + } + + // Parse the response as CloudletManifest + var manifest CloudletManifest + if err := c.parseDirectJSONResponse(resp, &manifest); err != nil { + return nil, fmt.Errorf("GetCloudletManifest failed to parse response: %w", err) + } + + c.logf("GetCloudletManifest: retrieved manifest for %s/%s", + cloudletKey.Organization, cloudletKey.Name) + + return &manifest, nil +} + +// GetCloudletResourceUsage retrieves resource usage information for a cloudlet +// Maps to POST /auth/ctrl/GetCloudletResourceUsage +func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletResourceUsage, error) { + transport := c.getTransport() + url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletResourceUsage" + + filter := CloudletFilter{ + Cloudlet: Cloudlet{Key: cloudletKey}, + Region: region, + } + + resp, err := transport.Call(ctx, "POST", url, filter) + if err != nil { + return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w", + cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) + } + + if resp.StatusCode >= 400 { + return nil, c.handleErrorResponse(resp, "GetCloudletResourceUsage") + } + + // Parse the response as CloudletResourceUsage + var usage CloudletResourceUsage + if err := c.parseDirectJSONResponse(resp, &usage); err != nil { + return nil, fmt.Errorf("GetCloudletResourceUsage failed to parse response: %w", err) + } + + c.logf("GetCloudletResourceUsage: retrieved usage for %s/%s", + cloudletKey.Organization, cloudletKey.Name) + + return &usage, nil +} + +// parseStreamingCloudletResponse parses the EdgeXR streaming JSON response format for cloudlets +func (c *Client) parseStreamingCloudletResponse(resp *http.Response, result interface{}) error { + var responses []Response[Cloudlet] + + parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { + var response Response[Cloudlet] + if err := json.Unmarshal(line, &response); err != nil { + return err + } + responses = append(responses, response) + return nil + }) + + if parseErr != nil { + return parseErr + } + + // Extract data from responses + var cloudlets []Cloudlet + var messages []string + + for _, response := range responses { + if response.HasData() { + cloudlets = append(cloudlets, response.Data) + } + if response.IsMessage() { + messages = append(messages, response.Data.GetMessage()) + } + } + + // If we have error messages, return them + if len(messages) > 0 { + return &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + } + } + + // Set result based on type + switch v := result.(type) { + case *[]Cloudlet: + *v = cloudlets + default: + return fmt.Errorf("unsupported result type: %T", result) + } + + return nil +} + +// parseDirectJSONResponse parses a direct JSON response (not streaming) +func (c *Client) parseDirectJSONResponse(resp *http.Response, result interface{}) error { + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(result); err != nil { + return fmt.Errorf("failed to decode JSON response: %w", err) + } + return nil +} diff --git a/sdk/edgeconnect/v2/cloudlet_test.go b/sdk/edgeconnect/v2/cloudlet_test.go new file mode 100644 index 0000000..8f2cc06 --- /dev/null +++ b/sdk/edgeconnect/v2/cloudlet_test.go @@ -0,0 +1,408 @@ +// ABOUTME: Unit tests for Cloudlet management APIs using httptest mock server +// ABOUTME: Tests create, show, list, delete, manifest, and resource usage operations + +package v2 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateCloudlet(t *testing.T) { + tests := []struct { + name string + input *NewCloudletInput + mockStatusCode int + mockResponse string + expectError bool + }{ + { + name: "successful creation", + input: &NewCloudletInput{ + Region: "us-west", + Cloudlet: Cloudlet{ + Key: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + Location: Location{ + Latitude: 37.7749, + Longitude: -122.4194, + }, + IpSupport: "IpSupportDynamic", + NumDynamicIps: 10, + }, + }, + mockStatusCode: 200, + mockResponse: `{"message": "success"}`, + expectError: false, + }, + { + name: "validation error", + input: &NewCloudletInput{ + Region: "us-west", + Cloudlet: Cloudlet{ + Key: CloudletKey{ + Organization: "", + Name: "testcloudlet", + }, + }, + }, + mockStatusCode: 400, + mockResponse: `{"message": "organization is required"}`, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/CreateCloudlet", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + w.WriteHeader(tt.mockStatusCode) + w.Write([]byte(tt.mockResponse)) + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, + WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), + WithAuthProvider(NewStaticTokenProvider("test-token")), + ) + + // Execute test + ctx := context.Background() + err := client.CreateCloudlet(ctx, tt.input) + + // Verify results + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestShowCloudlet(t *testing.T) { + tests := []struct { + name string + cloudletKey CloudletKey + region string + mockStatusCode int + mockResponse string + expectError bool + expectNotFound bool + }{ + { + name: "successful show", + cloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + region: "us-west", + mockStatusCode: 200, + mockResponse: `{"data": {"key": {"organization": "cloudletorg", "name": "testcloudlet"}, "state": "Ready", "location": {"latitude": 37.7749, "longitude": -122.4194}}} +`, + expectError: false, + expectNotFound: false, + }, + { + name: "cloudlet not found", + cloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "nonexistent", + }, + region: "us-west", + mockStatusCode: 404, + mockResponse: "", + expectError: true, + expectNotFound: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path) + + w.WriteHeader(tt.mockStatusCode) + if tt.mockResponse != "" { + w.Write([]byte(tt.mockResponse)) + } + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, + WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), + ) + + // Execute test + ctx := context.Background() + cloudlet, err := client.ShowCloudlet(ctx, tt.cloudletKey, tt.region) + + // Verify results + if tt.expectError { + assert.Error(t, err) + if tt.expectNotFound { + assert.Contains(t, err.Error(), "resource not found") + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.cloudletKey.Organization, cloudlet.Key.Organization) + assert.Equal(t, tt.cloudletKey.Name, cloudlet.Key.Name) + assert.Equal(t, "Ready", cloudlet.State) + } + }) + } +} + +func TestShowCloudlets(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path) + + // Verify request body + var filter CloudletFilter + err := json.NewDecoder(r.Body).Decode(&filter) + require.NoError(t, err) + assert.Equal(t, "cloudletorg", filter.Cloudlet.Key.Organization) + assert.Equal(t, "us-west", filter.Region) + + // Return multiple cloudlets + response := `{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet1"}, "state": "Ready"}} +{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}} +` + w.WriteHeader(200) + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL) + ctx := context.Background() + + cloudlets, err := client.ShowCloudlets(ctx, CloudletKey{Organization: "cloudletorg"}, "us-west") + + require.NoError(t, err) + assert.Len(t, cloudlets, 2) + assert.Equal(t, "cloudlet1", cloudlets[0].Key.Name) + assert.Equal(t, "Ready", cloudlets[0].State) + assert.Equal(t, "cloudlet2", cloudlets[1].Key.Name) + assert.Equal(t, "Creating", cloudlets[1].State) +} + +func TestDeleteCloudlet(t *testing.T) { + tests := []struct { + name string + cloudletKey CloudletKey + region string + mockStatusCode int + expectError bool + }{ + { + name: "successful deletion", + cloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + region: "us-west", + mockStatusCode: 200, + expectError: false, + }, + { + name: "already deleted (404 ok)", + cloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + region: "us-west", + mockStatusCode: 404, + expectError: false, + }, + { + name: "server error", + cloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + region: "us-west", + mockStatusCode: 500, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/DeleteCloudlet", r.URL.Path) + + w.WriteHeader(tt.mockStatusCode) + })) + defer server.Close() + + client := NewClient(server.URL) + ctx := context.Background() + + err := client.DeleteCloudlet(ctx, tt.cloudletKey, tt.region) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestGetCloudletManifest(t *testing.T) { + tests := []struct { + name string + cloudletKey CloudletKey + region string + mockStatusCode int + mockResponse string + expectError bool + expectNotFound bool + }{ + { + name: "successful manifest retrieval", + cloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + region: "us-west", + mockStatusCode: 200, + mockResponse: `{"manifest": "apiVersion: v1\nkind: Deployment\nmetadata:\n name: test", "last_modified": "2024-01-01T00:00:00Z"}`, + expectError: false, + expectNotFound: false, + }, + { + name: "manifest not found", + cloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "nonexistent", + }, + region: "us-west", + mockStatusCode: 404, + mockResponse: "", + expectError: true, + expectNotFound: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletManifest", r.URL.Path) + + w.WriteHeader(tt.mockStatusCode) + if tt.mockResponse != "" { + w.Write([]byte(tt.mockResponse)) + } + })) + defer server.Close() + + client := NewClient(server.URL) + ctx := context.Background() + + manifest, err := client.GetCloudletManifest(ctx, tt.cloudletKey, tt.region) + + if tt.expectError { + assert.Error(t, err) + if tt.expectNotFound { + assert.Contains(t, err.Error(), "resource not found") + } + } else { + require.NoError(t, err) + assert.NotNil(t, manifest) + assert.Contains(t, manifest.Manifest, "apiVersion: v1") + } + }) + } +} + +func TestGetCloudletResourceUsage(t *testing.T) { + tests := []struct { + name string + cloudletKey CloudletKey + region string + mockStatusCode int + mockResponse string + expectError bool + expectNotFound bool + }{ + { + name: "successful usage retrieval", + cloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "testcloudlet", + }, + region: "us-west", + mockStatusCode: 200, + mockResponse: `{"cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}, "region": "us-west", "usage": {"cpu": "50%", "memory": "30%", "disk": "20%"}}`, + expectError: false, + expectNotFound: false, + }, + { + name: "usage not found", + cloudletKey: CloudletKey{ + Organization: "cloudletorg", + Name: "nonexistent", + }, + region: "us-west", + mockStatusCode: 404, + mockResponse: "", + expectError: true, + expectNotFound: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletResourceUsage", r.URL.Path) + + w.WriteHeader(tt.mockStatusCode) + if tt.mockResponse != "" { + w.Write([]byte(tt.mockResponse)) + } + })) + defer server.Close() + + client := NewClient(server.URL) + ctx := context.Background() + + usage, err := client.GetCloudletResourceUsage(ctx, tt.cloudletKey, tt.region) + + if tt.expectError { + assert.Error(t, err) + if tt.expectNotFound { + assert.Contains(t, err.Error(), "resource not found") + } + } else { + require.NoError(t, err) + assert.NotNil(t, usage) + assert.Equal(t, "cloudletorg", usage.CloudletKey.Organization) + assert.Equal(t, "testcloudlet", usage.CloudletKey.Name) + assert.Equal(t, "us-west", usage.Region) + assert.Contains(t, usage.Usage, "cpu") + } + }) + } +} diff --git a/sdk/edgeconnect/v2/types.go b/sdk/edgeconnect/v2/types.go new file mode 100644 index 0000000..82995e0 --- /dev/null +++ b/sdk/edgeconnect/v2/types.go @@ -0,0 +1,407 @@ +// ABOUTME: Core type definitions for EdgeXR Master Controller SDK +// ABOUTME: These types are based on the swagger API specification and existing client patterns + +package v2 + +import ( + "encoding/json" + "fmt" + "time" +) + +// App field constants for partial updates (based on EdgeXR API specification) +const ( + AppFieldKey = "2" + AppFieldKeyOrganization = "2.1" + AppFieldKeyName = "2.2" + AppFieldKeyVersion = "2.3" + AppFieldImagePath = "4" + AppFieldImageType = "5" + AppFieldAccessPorts = "7" + AppFieldDefaultFlavor = "9" + AppFieldDefaultFlavorName = "9.1" + AppFieldAuthPublicKey = "12" + AppFieldCommand = "13" + AppFieldAnnotations = "14" + AppFieldDeployment = "15" + AppFieldDeploymentManifest = "16" + AppFieldDeploymentGenerator = "17" + AppFieldAndroidPackageName = "18" + AppFieldDelOpt = "20" + AppFieldConfigs = "21" + AppFieldConfigsKind = "21.1" + AppFieldConfigsConfig = "21.2" + AppFieldScaleWithCluster = "22" + AppFieldInternalPorts = "23" + AppFieldRevision = "24" + AppFieldOfficialFqdn = "25" + AppFieldMd5Sum = "26" + AppFieldAutoProvPolicy = "28" + AppFieldAccessType = "29" + AppFieldDeletePrepare = "31" + AppFieldAutoProvPolicies = "32" + AppFieldTemplateDelimiter = "33" + AppFieldSkipHcPorts = "34" + AppFieldCreatedAt = "35" + AppFieldCreatedAtSeconds = "35.1" + AppFieldCreatedAtNanos = "35.2" + AppFieldUpdatedAt = "36" + AppFieldUpdatedAtSeconds = "36.1" + AppFieldUpdatedAtNanos = "36.2" + AppFieldTrusted = "37" + AppFieldRequiredOutboundConnections = "38" + AppFieldAllowServerless = "39" + AppFieldServerlessConfig = "40" + AppFieldVmAppOsType = "41" + AppFieldAlertPolicies = "42" + AppFieldQosSessionProfile = "43" + AppFieldQosSessionDuration = "44" +) + +// AppInstance field constants for partial updates (based on EdgeXR API specification) +const ( + AppInstFieldKey = "2" + AppInstFieldKeyAppKey = "2.1" + AppInstFieldKeyAppKeyOrganization = "2.1.1" + AppInstFieldKeyAppKeyName = "2.1.2" + AppInstFieldKeyAppKeyVersion = "2.1.3" + AppInstFieldKeyClusterInstKey = "2.4" + AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1" + AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1" + AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2" + AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1" + AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2" + AppInstFieldKeyClusterInstKeyCloudletKeyFederatedOrganization = "2.4.2.3" + AppInstFieldKeyClusterInstKeyOrganization = "2.4.3" + AppInstFieldCloudletLoc = "3" + AppInstFieldCloudletLocLatitude = "3.1" + AppInstFieldCloudletLocLongitude = "3.2" + AppInstFieldCloudletLocHorizontalAccuracy = "3.3" + AppInstFieldCloudletLocVerticalAccuracy = "3.4" + AppInstFieldCloudletLocAltitude = "3.5" + AppInstFieldCloudletLocCourse = "3.6" + AppInstFieldCloudletLocSpeed = "3.7" + AppInstFieldCloudletLocTimestamp = "3.8" + AppInstFieldCloudletLocTimestampSeconds = "3.8.1" + AppInstFieldCloudletLocTimestampNanos = "3.8.2" + AppInstFieldUri = "4" + AppInstFieldLiveness = "6" + AppInstFieldMappedPorts = "9" + AppInstFieldMappedPortsProto = "9.1" + AppInstFieldMappedPortsInternalPort = "9.2" + AppInstFieldMappedPortsPublicPort = "9.3" + AppInstFieldMappedPortsFqdnPrefix = "9.5" + AppInstFieldMappedPortsEndPort = "9.6" + AppInstFieldMappedPortsTls = "9.7" + AppInstFieldMappedPortsNginx = "9.8" + AppInstFieldMappedPortsMaxPktSize = "9.9" + AppInstFieldFlavor = "12" + AppInstFieldFlavorName = "12.1" + AppInstFieldState = "14" + AppInstFieldErrors = "15" + AppInstFieldCrmOverride = "16" + AppInstFieldRuntimeInfo = "17" + AppInstFieldRuntimeInfoContainerIds = "17.1" + AppInstFieldCreatedAt = "21" + AppInstFieldCreatedAtSeconds = "21.1" + AppInstFieldCreatedAtNanos = "21.2" + AppInstFieldAutoClusterIpAccess = "22" + AppInstFieldRevision = "24" + AppInstFieldForceUpdate = "25" + AppInstFieldUpdateMultiple = "26" + AppInstFieldConfigs = "27" + AppInstFieldConfigsKind = "27.1" + AppInstFieldConfigsConfig = "27.2" + AppInstFieldHealthCheck = "29" + AppInstFieldPowerState = "31" + AppInstFieldExternalVolumeSize = "32" + AppInstFieldAvailabilityZone = "33" + AppInstFieldVmFlavor = "34" + AppInstFieldOptRes = "35" + AppInstFieldUpdatedAt = "36" + AppInstFieldUpdatedAtSeconds = "36.1" + AppInstFieldUpdatedAtNanos = "36.2" + AppInstFieldRealClusterName = "37" + AppInstFieldInternalPortToLbIp = "38" + AppInstFieldInternalPortToLbIpKey = "38.1" + AppInstFieldInternalPortToLbIpValue = "38.2" + AppInstFieldDedicatedIp = "39" + AppInstFieldUniqueId = "40" + AppInstFieldDnsLabel = "41" +) + +// Message interface for types that can provide error messages +type Message interface { + GetMessage() string +} + +// Base message type for API responses +type msg struct { + Message string `json:"message,omitempty"` +} + +func (m msg) GetMessage() string { + return m.Message +} + +// AppKey uniquely identifies an application +type AppKey struct { + Organization string `json:"organization"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` +} + +// CloudletKey uniquely identifies a cloudlet +type CloudletKey struct { + Organization string `json:"organization"` + Name string `json:"name"` +} + +// AppInstanceKey uniquely identifies an application instance +type AppInstanceKey struct { + Organization string `json:"organization"` + Name string `json:"name"` + CloudletKey CloudletKey `json:"cloudlet_key"` +} + +// Flavor defines resource allocation for instances +type Flavor struct { + Name string `json:"name"` +} + +// SecurityRule defines network access rules +type SecurityRule struct { + PortRangeMax int `json:"port_range_max"` + PortRangeMin int `json:"port_range_min"` + Protocol string `json:"protocol"` + RemoteCIDR string `json:"remote_cidr"` +} + +// App represents an application definition +type App struct { + msg `json:",inline"` + Key AppKey `json:"key"` + Deployment string `json:"deployment,omitempty"` + ImageType string `json:"image_type,omitempty"` + ImagePath string `json:"image_path,omitempty"` + AccessPorts string `json:"access_ports,omitempty"` + AllowServerless bool `json:"allow_serverless,omitempty"` + DefaultFlavor Flavor `json:"defaultFlavor,omitempty"` + ServerlessConfig interface{} `json:"serverless_config,omitempty"` + DeploymentGenerator string `json:"deployment_generator,omitempty"` + DeploymentManifest string `json:"deployment_manifest,omitempty"` + RequiredOutboundConnections []SecurityRule `json:"required_outbound_connections"` + GlobalID string `json:"global_id,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Fields []string `json:"fields,omitempty"` +} + +// AppInstance represents a deployed application instance +type AppInstance struct { + msg `json:",inline"` + Key AppInstanceKey `json:"key"` + AppKey AppKey `json:"app_key,omitempty"` + CloudletLoc CloudletLoc `json:"cloudlet_loc,omitempty"` + Flavor Flavor `json:"flavor,omitempty"` + State string `json:"state,omitempty"` + IngressURL string `json:"ingress_url,omitempty"` + UniqueID string `json:"unique_id,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + PowerState string `json:"power_state,omitempty"` + Fields []string `json:"fields,omitempty"` +} + +// Cloudlet represents edge infrastructure +type Cloudlet struct { + msg `json:",inline"` + Key CloudletKey `json:"key"` + Location Location `json:"location"` + IpSupport string `json:"ip_support,omitempty"` + NumDynamicIps int32 `json:"num_dynamic_ips,omitempty"` + State string `json:"state,omitempty"` + Flavor Flavor `json:"flavor,omitempty"` + PhysicalName string `json:"physical_name,omitempty"` + Region string `json:"region,omitempty"` + NotifySrvAddr string `json:"notify_srv_addr,omitempty"` +} + +// Location represents geographical coordinates +type Location struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +// CloudletLoc represents geographical coordinates for cloudlets +type CloudletLoc struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +// Input types for API operations + +// NewAppInput represents input for creating an application +type NewAppInput struct { + Region string `json:"region"` + App App `json:"app"` +} + +// NewAppInstanceInput represents input for creating an app instance +type NewAppInstanceInput struct { + Region string `json:"region"` + AppInst AppInstance `json:"appinst"` +} + +// NewCloudletInput represents input for creating a cloudlet +type NewCloudletInput struct { + Region string `json:"region"` + Cloudlet Cloudlet `json:"cloudlet"` +} + +// UpdateAppInput represents input for updating an application +type UpdateAppInput struct { + Region string `json:"region"` + App App `json:"app"` +} + +// UpdateAppInstanceInput represents input for updating an app instance +type UpdateAppInstanceInput struct { + Region string `json:"region"` + AppInst AppInstance `json:"appinst"` +} + +// DeleteAppInput represents input for deleting an application +type DeleteAppInput struct { + Key AppKey `json:"key"` + Region string `json:"region"` +} + +// DeleteAppInstanceInput represents input for deleting an app instance +type DeleteAppInstanceInput struct { + Key AppInstanceKey `json:"key"` +} + +// Response wrapper types + +// Response wraps a single API response +type Response[T Message] struct { + Data T `json:"data"` +} + +func (res *Response[T]) HasData() bool { + return !res.IsMessage() +} + +func (res *Response[T]) IsMessage() bool { + return res.Data.GetMessage() != "" +} + +// ResultResponse represents an API result with error code +type ResultResponse struct { + Result struct { + Message string `json:"message"` + Code int `json:"code"` + } `json:"result"` +} + +func (r *ResultResponse) IsError() bool { + return r.Result.Code >= 400 +} + +func (r *ResultResponse) GetMessage() string { + return r.Result.Message +} + +func (r *ResultResponse) GetCode() int { + return r.Result.Code +} + +// Responses wraps multiple API responses with metadata +type Responses[T Message] struct { + Responses []Response[T] `json:"responses,omitempty"` + StatusCode int `json:"-"` +} + +func (r *Responses[T]) GetData() []T { + var data []T + for _, v := range r.Responses { + if v.HasData() { + data = append(data, v.Data) + } + } + return data +} + +func (r *Responses[T]) GetMessages() []string { + var messages []string + for _, v := range r.Responses { + if v.IsMessage() { + messages = append(messages, v.Data.GetMessage()) + } + } + return messages +} + +func (r *Responses[T]) IsSuccessful() bool { + return r.StatusCode >= 200 && r.StatusCode < 400 +} + +func (r *Responses[T]) Error() error { + if r.IsSuccessful() { + return nil + } + return &APIError{ + StatusCode: r.StatusCode, + Messages: r.GetMessages(), + } +} + +// APIError represents an API error with details +type APIError struct { + StatusCode int `json:"status_code"` + Code string `json:"code,omitempty"` + Messages []string `json:"messages,omitempty"` + Body []byte `json:"-"` +} + +func (e *APIError) Error() string { + jsonErr, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf("API error: %v", err) + } + return fmt.Sprintf("API error: %s", jsonErr) +} + +// Filter types for querying + +// AppFilter represents filters for app queries +type AppFilter struct { + App App `json:"app"` + Region string `json:"region"` +} + +// AppInstanceFilter represents filters for app instance queries +type AppInstanceFilter struct { + AppInstance AppInstance `json:"appinst"` + Region string `json:"region"` +} + +// CloudletFilter represents filters for cloudlet queries +type CloudletFilter struct { + Cloudlet Cloudlet `json:"cloudlet"` + Region string `json:"region"` +} + +// CloudletManifest represents cloudlet deployment manifest +type CloudletManifest struct { + Manifest string `json:"manifest"` + LastModified time.Time `json:"last_modified,omitempty"` +} + +// CloudletResourceUsage represents cloudlet resource utilization +type CloudletResourceUsage struct { + CloudletKey CloudletKey `json:"cloudlet_key"` + Region string `json:"region"` + Usage map[string]interface{} `json:"usage"` +} diff --git a/sdk/examples/comprehensive/main.go b/sdk/examples/comprehensive/main.go index 616279f..d3fb922 100644 --- a/sdk/examples/comprehensive/main.go +++ b/sdk/examples/comprehensive/main.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ) func main() { @@ -24,20 +24,20 @@ func main() { username := getEnvOrDefault("EDGEXR_USERNAME", "") password := getEnvOrDefault("EDGEXR_PASSWORD", "") - var client *edgeconnect.Client + var client *v2.Client if token != "" { fmt.Println("🔐 Using Bearer token authentication") - client = edgeconnect.NewClient(baseURL, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), - edgeconnect.WithLogger(log.Default()), + client = v2.NewClient(baseURL, + v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + v2.WithAuthProvider(v2.NewStaticTokenProvider(token)), + v2.WithLogger(log.Default()), ) } else if username != "" && password != "" { fmt.Println("🔐 Using username/password authentication") - client = edgeconnect.NewClientWithCredentials(baseURL, username, password, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithLogger(log.Default()), + client = v2.NewClientWithCredentials(baseURL, username, password, + v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + v2.WithLogger(log.Default()), ) } else { log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD") @@ -85,15 +85,15 @@ type WorkflowConfig struct { FlavorName string } -func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config WorkflowConfig) error { +func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config WorkflowConfig) error { fmt.Println("═══ Phase 1: Application Management ═══") // 1. Create Application fmt.Println("\n1️⃣ Creating application...") - app := &edgeconnect.NewAppInput{ + app := &v2.NewAppInput{ Region: config.Region, - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ + App: v2.App{ + Key: v2.AppKey{ Organization: config.Organization, Name: config.AppName, Version: config.AppVersion, @@ -101,10 +101,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Deployment: "kubernetes", ImageType: "ImageTypeDocker", // field is ignored ImagePath: "https://registry-1.docker.io/library/nginx:latest", // must be set. Even for kubernetes - DefaultFlavor: edgeconnect.Flavor{Name: config.FlavorName}, + DefaultFlavor: v2.Flavor{Name: config.FlavorName}, ServerlessConfig: struct{}{}, // must be set AllowServerless: true, // must be set to true for kubernetes - RequiredOutboundConnections: []edgeconnect.SecurityRule{ + RequiredOutboundConnections: []v2.SecurityRule{ { Protocol: "tcp", PortRangeMin: 80, @@ -128,7 +128,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 2. Show Application Details fmt.Println("\n2️⃣ Querying application details...") - appKey := edgeconnect.AppKey{ + appKey := v2.AppKey{ Organization: config.Organization, Name: config.AppName, Version: config.AppVersion, @@ -146,7 +146,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 3. List Applications in Organization fmt.Println("\n3️⃣ Listing applications in organization...") - filter := edgeconnect.AppKey{Organization: config.Organization} + filter := v2.AppKey{Organization: config.Organization} apps, err := c.ShowApps(ctx, filter, config.Region) if err != nil { return fmt.Errorf("failed to list apps: %w", err) @@ -160,19 +160,19 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 4. Create Application Instance fmt.Println("\n4️⃣ Creating application instance...") - instance := &edgeconnect.NewAppInstanceInput{ + instance := &v2.NewAppInstanceInput{ Region: config.Region, - AppInst: edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ + AppInst: v2.AppInstance{ + Key: v2.AppInstanceKey{ Organization: config.Organization, Name: config.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: config.CloudletOrg, Name: config.CloudletName, }, }, AppKey: appKey, - Flavor: edgeconnect.Flavor{Name: config.FlavorName}, + Flavor: v2.Flavor{Name: config.FlavorName}, }, } @@ -184,10 +184,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 5. Wait for Application Instance to be Ready fmt.Println("\n5️⃣ Waiting for application instance to be ready...") - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := v2.AppInstanceKey{ Organization: config.Organization, Name: config.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: v2.CloudletKey{ Organization: config.CloudletOrg, Name: config.CloudletName, }, @@ -207,7 +207,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 6. List Application Instances fmt.Println("\n6️⃣ Listing application instances...") - instances, err := c.ShowAppInstances(ctx, edgeconnect.AppInstanceKey{Organization: config.Organization}, config.Region) + instances, err := c.ShowAppInstances(ctx, v2.AppInstanceKey{Organization: config.Organization}, config.Region) if err != nil { return fmt.Errorf("failed to list app instances: %w", err) } @@ -228,7 +228,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 8. Show Cloudlet Details fmt.Println("\n8️⃣ Querying cloudlet information...") - cloudletKey := edgeconnect.CloudletKey{ + cloudletKey := v2.CloudletKey{ Organization: config.CloudletOrg, Name: config.CloudletName, } @@ -287,7 +287,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 13. Verify Cleanup fmt.Println("\n1️⃣3️⃣ Verifying cleanup...") _, err = c.ShowApp(ctx, appKey, config.Region) - if err != nil && fmt.Sprintf("%v", err) == edgeconnect.ErrResourceNotFound.Error() { + if err != nil && fmt.Sprintf("%v", err) == v2.ErrResourceNotFound.Error() { fmt.Printf("✅ Cleanup verified - app no longer exists\n") } else if err != nil { fmt.Printf("✅ Cleanup appears successful (verification returned: %v)\n", err) @@ -306,7 +306,7 @@ func getEnvOrDefault(key, defaultValue string) string { } // waitForInstanceReady polls the instance status until it's no longer "Creating" or timeout -func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKey edgeconnect.AppInstanceKey, region string, timeout time.Duration) (edgeconnect.AppInstance, error) { +func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppInstanceKey, region string, timeout time.Duration) (v2.AppInstance, error) { timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -318,7 +318,7 @@ func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKe for { select { case <-timeoutCtx.Done(): - return edgeconnect.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout) + return v2.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout) case <-ticker.C: instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, region) diff --git a/sdk/examples/deploy_app.go b/sdk/examples/deploy_app.go index b413886..84297dc 100644 --- a/sdk/examples/deploy_app.go +++ b/sdk/examples/deploy_app.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect" + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect/v2" ) func main() { @@ -24,22 +24,22 @@ func main() { username := getEnvOrDefault("EDGEXR_USERNAME", "") password := getEnvOrDefault("EDGEXR_PASSWORD", "") - var edgeClient *edgeconnect.Client + var edgeClient *v2.Client if token != "" { // Use static token authentication fmt.Println("🔐 Using Bearer token authentication") - edgeClient = edgeconnect.NewClient(baseURL, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), - edgeconnect.WithLogger(log.Default()), + edgeClient = v2.NewClient(baseURL, + v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + v2.WithAuthProvider(v2.NewStaticTokenProvider(token)), + v2.WithLogger(log.Default()), ) } else if username != "" && password != "" { // Use username/password authentication (matches existing client pattern) fmt.Println("🔐 Using username/password authentication") - edgeClient = edgeconnect.NewClientWithCredentials(baseURL, username, password, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithLogger(log.Default()), + edgeClient = v2.NewClientWithCredentials(baseURL, username, password, + v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + v2.WithLogger(log.Default()), ) } else { log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD") @@ -48,10 +48,10 @@ func main() { ctx := context.Background() // Example application to deploy - app := &edgeconnect.NewAppInput{ + app := &v2.NewAppInput{ Region: "EU", - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ + App: v2.App{ + Key: v2.AppKey{ Organization: "edp2", Name: "my-edge-app", Version: "1.0.0", @@ -59,7 +59,7 @@ func main() { Deployment: "docker", ImageType: "ImageTypeDocker", ImagePath: "https://registry-1.docker.io/library/nginx:latest", - DefaultFlavor: edgeconnect.Flavor{Name: "EU.small"}, + DefaultFlavor: v2.Flavor{Name: "EU.small"}, ServerlessConfig: struct{}{}, AllowServerless: false, }, @@ -73,7 +73,7 @@ func main() { fmt.Println("✅ SDK example completed successfully!") } -func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client, input *edgeconnect.NewAppInput) error { +func demonstrateAppLifecycle(ctx context.Context, edgeClient *v2.Client, input *v2.NewAppInput) error { appKey := input.App.Key region := input.Region @@ -98,7 +98,7 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client // Step 3: List applications in the organization fmt.Println("\n3. Listing applications...") - filter := edgeconnect.AppKey{Organization: appKey.Organization} + filter := v2.AppKey{Organization: appKey.Organization} apps, err := edgeClient.ShowApps(ctx, filter, region) if err != nil { return fmt.Errorf("failed to list apps: %w", err) @@ -116,7 +116,7 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client fmt.Println("\n5. Verifying deletion...") _, err = edgeClient.ShowApp(ctx, appKey, region) if err != nil { - if strings.Contains(fmt.Sprintf("%v", err), edgeconnect.ErrResourceNotFound.Error()) { + if strings.Contains(fmt.Sprintf("%v", err), v2.ErrResourceNotFound.Error()) { fmt.Printf("✅ App successfully deleted (not found)\n") } else { return fmt.Errorf("unexpected error verifying deletion: %w", err)