refactor(sdk): restructure to follow Go module versioning conventions

Reorganize SDK to support both v1 and v2 APIs following Go conventions:
- sdk/edgeconnect/ now contains v1 SDK (from revision/v1 branch)
- sdk/edgeconnect/v2/ contains v2 SDK with package v2
- Update all CLI and internal imports to use v2 path
- Update SDK examples and documentation for v2 import path

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Richard Robert Reitz 2025-10-20 13:34:22 +02:00
parent 1413836b68
commit 3486b2228d
24 changed files with 3328 additions and 278 deletions

View file

@ -9,7 +9,7 @@ import (
"os" "os"
"time" "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/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@ -50,7 +50,7 @@ func validateBaseURL(baseURL string) error {
return nil return nil
} }
func newSDKClient() *edgeconnect.Client { func newSDKClient() *v2.Client {
baseURL := viper.GetString("base_url") baseURL := viper.GetString("base_url")
username := viper.GetString("username") username := viper.GetString("username")
password := viper.GetString("password") password := viper.GetString("password")
@ -62,22 +62,22 @@ func newSDKClient() *edgeconnect.Client {
} }
// Build options // Build options
opts := []edgeconnect.Option{ opts := []v2.Option{
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
} }
// Add logger only if debug flag is set // Add logger only if debug flag is set
if debug { if debug {
logger := log.New(os.Stderr, "[DEBUG] ", log.LstdFlags) logger := log.New(os.Stderr, "[DEBUG] ", log.LstdFlags)
opts = append(opts, edgeconnect.WithLogger(logger)) opts = append(opts, v2.WithLogger(logger))
} }
if username != "" && password != "" { 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 // 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{ var appCmd = &cobra.Command{
@ -91,10 +91,10 @@ var createAppCmd = &cobra.Command{
Short: "Create a new Edge Connect application", Short: "Create a new Edge Connect application",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() c := newSDKClient()
input := &edgeconnect.NewAppInput{ input := &v2.NewAppInput{
Region: region, Region: region,
App: edgeconnect.App{ App: v2.App{
Key: edgeconnect.AppKey{ Key: v2.AppKey{
Organization: organization, Organization: organization,
Name: appName, Name: appName,
Version: appVersion, Version: appVersion,
@ -116,7 +116,7 @@ var showAppCmd = &cobra.Command{
Short: "Show details of an Edge Connect application", Short: "Show details of an Edge Connect application",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() c := newSDKClient()
appKey := edgeconnect.AppKey{ appKey := v2.AppKey{
Organization: organization, Organization: organization,
Name: appName, Name: appName,
Version: appVersion, Version: appVersion,
@ -136,7 +136,7 @@ var listAppsCmd = &cobra.Command{
Short: "List Edge Connect applications", Short: "List Edge Connect applications",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() c := newSDKClient()
appKey := edgeconnect.AppKey{ appKey := v2.AppKey{
Organization: organization, Organization: organization,
Name: appName, Name: appName,
Version: appVersion, Version: appVersion,
@ -159,7 +159,7 @@ var deleteAppCmd = &cobra.Command{
Short: "Delete an Edge Connect application", Short: "Delete an Edge Connect application",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() c := newSDKClient()
appKey := edgeconnect.AppKey{ appKey := v2.AppKey{
Organization: organization, Organization: organization,
Name: appName, Name: appName,
Version: appVersion, Version: appVersion,

View file

@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"os" "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" "github.com/spf13/cobra"
) )
@ -27,23 +27,23 @@ var createInstanceCmd = &cobra.Command{
Short: "Create a new Edge Connect application instance", Short: "Create a new Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() c := newSDKClient()
input := &edgeconnect.NewAppInstanceInput{ input := &v2.NewAppInstanceInput{
Region: region, Region: region,
AppInst: edgeconnect.AppInstance{ AppInst: v2.AppInstance{
Key: edgeconnect.AppInstanceKey{ Key: v2.AppInstanceKey{
Organization: organization, Organization: organization,
Name: instanceName, Name: instanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: v2.CloudletKey{
Organization: cloudletOrg, Organization: cloudletOrg,
Name: cloudletName, Name: cloudletName,
}, },
}, },
AppKey: edgeconnect.AppKey{ AppKey: v2.AppKey{
Organization: organization, Organization: organization,
Name: appName, Name: appName,
Version: appVersion, Version: appVersion,
}, },
Flavor: edgeconnect.Flavor{ Flavor: v2.Flavor{
Name: flavorName, Name: flavorName,
}, },
}, },
@ -63,10 +63,10 @@ var showInstanceCmd = &cobra.Command{
Short: "Show details of an Edge Connect application instance", Short: "Show details of an Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() c := newSDKClient()
instanceKey := edgeconnect.AppInstanceKey{ instanceKey := v2.AppInstanceKey{
Organization: organization, Organization: organization,
Name: instanceName, Name: instanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: v2.CloudletKey{
Organization: cloudletOrg, Organization: cloudletOrg,
Name: cloudletName, Name: cloudletName,
}, },
@ -86,10 +86,10 @@ var listInstancesCmd = &cobra.Command{
Short: "List Edge Connect application instances", Short: "List Edge Connect application instances",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() c := newSDKClient()
instanceKey := edgeconnect.AppInstanceKey{ instanceKey := v2.AppInstanceKey{
Organization: organization, Organization: organization,
Name: instanceName, Name: instanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: v2.CloudletKey{
Organization: cloudletOrg, Organization: cloudletOrg,
Name: cloudletName, Name: cloudletName,
}, },
@ -112,10 +112,10 @@ var deleteInstanceCmd = &cobra.Command{
Short: "Delete an Edge Connect application instance", Short: "Delete an Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient() c := newSDKClient()
instanceKey := edgeconnect.AppInstanceKey{ instanceKey := v2.AppInstanceKey{
Organization: organization, Organization: organization,
Name: instanceName, Name: instanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: v2.CloudletKey{
Organization: cloudletOrg, Organization: cloudletOrg,
Name: cloudletName, Name: cloudletName,
}, },

View file

@ -8,7 +8,7 @@ import (
"time" "time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "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 // 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 // rollbackApp deletes an application that was created
func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error { func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
appKey := edgeconnect.AppKey{ appKey := v2.AppKey{
Organization: plan.AppAction.Desired.Organization, Organization: plan.AppAction.Desired.Organization,
Name: plan.AppAction.Desired.Name, Name: plan.AppAction.Desired.Name,
Version: plan.AppAction.Desired.Version, 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 // Find the instance action to get the details
for _, instanceAction := range plan.InstanceActions { for _, instanceAction := range plan.InstanceActions {
if instanceAction.InstanceName == action.Target { if instanceAction.InstanceName == action.Target {
instanceKey := edgeconnect.AppInstanceKey{ instanceKey := v2.AppInstanceKey{
Organization: plan.AppAction.Desired.Organization, Organization: plan.AppAction.Desired.Organization,
Name: instanceAction.InstanceName, Name: instanceAction.InstanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: v2.CloudletKey{
Organization: instanceAction.Target.CloudletOrg, Organization: instanceAction.Target.CloudletOrg,
Name: instanceAction.Target.CloudletName, Name: instanceAction.Target.CloudletName,
}, },

View file

@ -11,7 +11,7 @@ import (
"time" "time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "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/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -22,32 +22,32 @@ type MockResourceClient struct {
MockEdgeConnectClient 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) args := m.Called(ctx, input)
return args.Error(0) 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) args := m.Called(ctx, input)
return args.Error(0) 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) args := m.Called(ctx, appKey, region)
return args.Error(0) 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) args := m.Called(ctx, input)
return args.Error(0) 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) args := m.Called(ctx, input)
return args.Error(0) 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) args := m.Called(ctx, instanceKey, region)
return args.Error(0) return args.Error(0)
} }
@ -185,9 +185,9 @@ func TestApplyDeploymentSuccess(t *testing.T) {
config := createTestManagerConfig(t) config := createTestManagerConfig(t)
// Mock successful operations // Mock successful operations
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
Return(nil) Return(nil)
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
Return(nil) Return(nil)
ctx := context.Background() ctx := context.Background()
@ -216,8 +216,8 @@ func TestApplyDeploymentAppFailure(t *testing.T) {
config := createTestManagerConfig(t) config := createTestManagerConfig(t)
// Mock app creation failure - deployment should stop here // Mock app creation failure - deployment should stop here
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) Return(&v2.APIError{StatusCode: 500, Messages: []string{"Server error"}})
ctx := context.Background() ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content") result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
@ -241,13 +241,13 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
config := createTestManagerConfig(t) config := createTestManagerConfig(t)
// Mock successful app creation but failed instance creation // 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) Return(nil)
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}}) Return(&v2.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}})
// Mock rollback operations // 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) Return(nil)
ctx := context.Background() ctx := context.Background()
@ -333,9 +333,9 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
config := createTestManagerConfig(t) config := createTestManagerConfig(t)
// Mock successful operations // Mock successful operations
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
Return(nil) Return(nil)
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
Return(nil) Return(nil)
ctx := context.Background() ctx := context.Background()
@ -421,9 +421,9 @@ func TestRollbackDeployment(t *testing.T) {
} }
// Mock rollback operations // 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) Return(nil)
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil) Return(nil)
ctx := context.Background() ctx := context.Background()
@ -453,8 +453,8 @@ func TestRollbackDeploymentFailure(t *testing.T) {
} }
// Mock rollback failure // Mock rollback failure
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Delete failed"}}) Return(&v2.APIError{StatusCode: 500, Messages: []string{"Delete failed"}})
ctx := context.Background() ctx := context.Background()
err := manager.RollbackDeployment(ctx, result) err := manager.RollbackDeployment(ctx, result)

View file

@ -12,19 +12,19 @@ import (
"time" "time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "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 // EdgeConnectClientInterface defines the methods needed for deployment planning
type EdgeConnectClientInterface interface { type EdgeConnectClientInterface interface {
ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error)
CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error CreateApp(ctx context.Context, input *v2.NewAppInput) error
UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error
DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error
ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) (v2.AppInstance, error)
CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error
UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error
} }
// Planner defines the interface for deployment planning // 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) timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
appKey := edgeconnect.AppKey{ appKey := v2.AppKey{
Organization: desired.Organization, Organization: desired.Organization,
Name: desired.Name, Name: desired.Name,
Version: desired.Version, Version: desired.Version,
@ -339,10 +339,10 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire
timeoutCtx, cancel := context.WithTimeout(ctx, timeout) timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
instanceKey := edgeconnect.AppInstanceKey{ instanceKey := v2.AppInstanceKey{
Organization: desired.Organization, Organization: desired.Organization,
Name: desired.Name, Name: desired.Name,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: v2.CloudletKey{
Organization: desired.CloudletOrg, Organization: desired.CloudletOrg,
Name: desired.CloudletName, Name: desired.CloudletName,
}, },

View file

@ -10,7 +10,7 @@ import (
"time" "time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "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/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -21,66 +21,66 @@ type MockEdgeConnectClient struct {
mock.Mock 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) args := m.Called(ctx, appKey, region)
if args.Get(0) == nil { 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) args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil { 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) args := m.Called(ctx, input)
return args.Error(0) 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) args := m.Called(ctx, input)
return args.Error(0) 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) args := m.Called(ctx, appKey, region)
return args.Error(0) 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) args := m.Called(ctx, input)
return args.Error(0) 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) args := m.Called(ctx, input)
return args.Error(0) 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) args := m.Called(ctx, instanceKey, region)
return args.Error(0) 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) args := m.Called(ctx, appKey, region)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Error(1) 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) args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Error(1) 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) { func TestNewPlanner(t *testing.T) {
@ -148,11 +148,11 @@ func TestPlanNewDeployment(t *testing.T) {
testConfig := createTestConfig(t) testConfig := createTestConfig(t)
// Mock API calls to return "not found" errors // Mock API calls to return "not found" errors
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
ctx := context.Background() ctx := context.Background()
result, err := planner.Plan(ctx, testConfig) 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 // Mock existing app with same manifest hash and outbound connections
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n" manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
existingApp := &edgeconnect.App{ existingApp := &v2.App{
Key: edgeconnect.AppKey{ Key: v2.AppKey{
Organization: "testorg", Organization: "testorg",
Name: "test-app", Name: "test-app",
Version: "1.0.0", Version: "1.0.0",
}, },
Deployment: "kubernetes", Deployment: "kubernetes",
DeploymentManifest: manifestContent, DeploymentManifest: manifestContent,
RequiredOutboundConnections: []edgeconnect.SecurityRule{ RequiredOutboundConnections: []v2.SecurityRule{
{ {
Protocol: "tcp", Protocol: "tcp",
PortRangeMin: 80, PortRangeMin: 80,
@ -206,31 +206,31 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) {
} }
// Mock existing instance // Mock existing instance
existingInstance := &edgeconnect.AppInstance{ existingInstance := &v2.AppInstance{
Key: edgeconnect.AppInstanceKey{ Key: v2.AppInstanceKey{
Organization: "testorg", Organization: "testorg",
Name: "test-app-1.0.0-instance", Name: "test-app-1.0.0-instance",
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: v2.CloudletKey{
Organization: "TestCloudletOrg", Organization: "TestCloudletOrg",
Name: "TestCloudlet", Name: "TestCloudlet",
}, },
}, },
AppKey: edgeconnect.AppKey{ AppKey: v2.AppKey{
Organization: "testorg", Organization: "testorg",
Name: "test-app", Name: "test-app",
Version: "1.0.0", Version: "1.0.0",
}, },
Flavor: edgeconnect.Flavor{ Flavor: v2.Flavor{
Name: "small", Name: "small",
}, },
State: "Ready", State: "Ready",
PowerState: "PowerOn", 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) 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) Return(*existingInstance, nil)
ctx := context.Background() ctx := context.Background()
@ -293,14 +293,14 @@ func TestPlanMultipleInfrastructures(t *testing.T) {
}) })
// Mock API calls to return "not found" errors // Mock API calls to return "not found" errors
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}}) Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "EU"). mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "EU").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}}) Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
ctx := context.Background() ctx := context.Background()
result, err := planner.Plan(ctx, testConfig) result, err := planner.Plan(ctx, testConfig)
@ -628,10 +628,10 @@ func TestIsResourceNotFoundError(t *testing.T) {
expected bool expected bool
}{ }{
{"nil error", nil, false}, {"nil error", nil, false},
{"not found error", &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true}, {"not found error", &v2.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true},
{"does not exist error", &edgeconnect.APIError{Messages: []string{"App does not exist"}}, true}, {"does not exist error", &v2.APIError{Messages: []string{"App does not exist"}}, true},
{"404 in message", &edgeconnect.APIError{Messages: []string{"HTTP 404 error"}}, true}, {"404 in message", &v2.APIError{Messages: []string{"HTTP 404 error"}}, true},
{"other error", &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false}, {"other error", &v2.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false},
} }
for _, tt := range tests { for _, tt := range tests {
@ -648,8 +648,8 @@ func TestPlanErrorHandling(t *testing.T) {
testConfig := createTestConfig(t) testConfig := createTestConfig(t)
// Mock API call to return a non-404 error // Mock API call to return a non-404 error
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) Return(nil, &v2.APIError{StatusCode: 500, Messages: []string{"Server error"}})
ctx := context.Background() ctx := context.Background()
result, err := planner.Plan(ctx, testConfig) result, err := planner.Plan(ctx, testConfig)

View file

@ -11,7 +11,7 @@ import (
"time" "time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "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 // 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") r.logf("Phase 2: Deleting existing application")
appKey := edgeconnect.AppKey{ appKey := v2.AppKey{
Organization: plan.AppAction.Desired.Organization, Organization: plan.AppAction.Desired.Organization,
Name: plan.AppAction.Desired.Name, Name: plan.AppAction.Desired.Name,
Version: plan.AppAction.Desired.Version, 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) // deleteInstance deletes an instance (reuse existing logic from manager.go)
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) { func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
instanceKey := edgeconnect.AppInstanceKey{ instanceKey := v2.AppInstanceKey{
Organization: action.Desired.Organization, Organization: action.Desired.Organization,
Name: action.InstanceName, Name: action.InstanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: v2.CloudletKey{
Organization: action.Target.CloudletOrg, Organization: action.Target.CloudletOrg,
Name: action.Target.CloudletName, 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) // createInstance creates an instance (extracted from manager.go logic)
func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) { func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
instanceInput := &edgeconnect.NewAppInstanceInput{ instanceInput := &v2.NewAppInstanceInput{
Region: action.Target.Region, Region: action.Target.Region,
AppInst: edgeconnect.AppInstance{ AppInst: v2.AppInstance{
Key: edgeconnect.AppInstanceKey{ Key: v2.AppInstanceKey{
Organization: action.Desired.Organization, Organization: action.Desired.Organization,
Name: action.InstanceName, Name: action.InstanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: v2.CloudletKey{
Organization: action.Target.CloudletOrg, Organization: action.Target.CloudletOrg,
Name: action.Target.CloudletName, Name: action.Target.CloudletName,
}, },
}, },
AppKey: edgeconnect.AppKey{ AppKey: v2.AppKey{
Organization: action.Desired.Organization, Organization: action.Desired.Organization,
Name: config.Metadata.Name, Name: config.Metadata.Name,
Version: config.Metadata.AppVersion, Version: config.Metadata.AppVersion,
}, },
Flavor: edgeconnect.Flavor{ Flavor: v2.Flavor{
Name: action.Target.FlavorName, 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) // 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) { 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 // Build the app create input - always create since recreate strategy deletes first
appInput := &edgeconnect.NewAppInput{ appInput := &v2.NewAppInput{
Region: action.Desired.Region, Region: action.Desired.Region,
App: edgeconnect.App{ App: v2.App{
Key: edgeconnect.AppKey{ Key: v2.AppKey{
Organization: action.Desired.Organization, Organization: action.Desired.Organization,
Name: action.Desired.Name, Name: action.Desired.Name,
Version: action.Desired.Version, Version: action.Desired.Version,
@ -493,7 +493,7 @@ func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppActi
ImageType: "ImageTypeDocker", ImageType: "ImageTypeDocker",
ImagePath: config.GetImagePath(), ImagePath: config.GetImagePath(),
AllowServerless: true, AllowServerless: true,
DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName}, DefaultFlavor: v2.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
ServerlessConfig: struct{}{}, ServerlessConfig: struct{}{},
DeploymentManifest: manifestContent, DeploymentManifest: manifestContent,
DeploymentGenerator: "kubernetes-basic", DeploymentGenerator: "kubernetes-basic",
@ -531,7 +531,7 @@ func isRetryableError(err error) bool {
} }
// Check if it's an APIError with a status code // Check if it's an APIError with a status code
var apiErr *edgeconnect.APIError var apiErr *v2.APIError
if errors.As(err, &apiErr) { if errors.As(err, &apiErr) {
// Don't retry client errors (4xx) // Don't retry client errors (4xx)
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {

View file

@ -8,11 +8,11 @@ import (
"time" "time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" "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) // 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 // ActionType represents the type of action to be performed
type ActionType string type ActionType string
@ -446,11 +446,11 @@ func (dp *DeploymentPlan) Clone() *DeploymentPlan {
} }
// convertNetworkRules converts config network rules to EdgeConnect SecurityRules // convertNetworkRules converts config network rules to EdgeConnect SecurityRules
func convertNetworkRules(network *config.NetworkConfig) []edgeconnect.SecurityRule { func convertNetworkRules(network *config.NetworkConfig) []v2.SecurityRule {
rules := make([]edgeconnect.SecurityRule, len(network.OutboundConnections)) rules := make([]v2.SecurityRule, len(network.OutboundConnections))
for i, conn := range network.OutboundConnections { for i, conn := range network.OutboundConnections {
rules[i] = edgeconnect.SecurityRule{ rules[i] = v2.SecurityRule{
Protocol: conn.Protocol, Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin, PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax, PortRangeMax: conn.PortRangeMax,

View file

@ -16,18 +16,18 @@ A comprehensive Go SDK for the EdgeXR Master Controller API, providing typed int
### Installation ### Installation
```go ```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 ### Authentication
```go ```go
// Username/password (recommended) // Username/password (recommended)
client := client.NewClientWithCredentials(baseURL, username, password) client := v2.NewClientWithCredentials(baseURL, username, password)
// Static Bearer token // Static Bearer token
client := client.NewClient(baseURL, client := v2.NewClient(baseURL,
client.WithAuthProvider(client.NewStaticTokenProvider(token))) v2.WithAuthProvider(v2.NewStaticTokenProvider(token)))
``` ```
### Basic Usage ### Basic Usage
@ -36,10 +36,10 @@ client := client.NewClient(baseURL,
ctx := context.Background() ctx := context.Background()
// Create an application // Create an application
app := &client.NewAppInput{ app := &v2.NewAppInput{
Region: "us-west", Region: "us-west",
App: client.App{ App: v2.App{
Key: client.AppKey{ Key: v2.AppKey{
Organization: "myorg", Organization: "myorg",
Name: "my-app", Name: "my-app",
Version: "1.0.0", 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) log.Fatal(err)
} }
// Deploy an application instance // Deploy an application instance
instance := &client.NewAppInstanceInput{ instance := &v2.NewAppInstanceInput{
Region: "us-west", Region: "us-west",
AppInst: client.AppInstance{ AppInst: v2.AppInstance{
Key: client.AppInstanceKey{ Key: v2.AppInstanceKey{
Organization: "myorg", Organization: "myorg",
Name: "my-instance", Name: "my-instance",
CloudletKey: client.CloudletKey{ CloudletKey: v2.CloudletKey{
Organization: "cloudlet-provider", Organization: "cloudlet-provider",
Name: "edge-cloudlet", Name: "edge-cloudlet",
}, },
}, },
AppKey: app.App.Key, 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) log.Fatal(err)
} }
``` ```
@ -101,22 +101,22 @@ if err := client.CreateAppInstance(ctx, instance); err != nil {
## Configuration Options ## Configuration Options
```go ```go
client := client.NewClient(baseURL, client := v2.NewClient(baseURL,
// Custom HTTP client with timeout // Custom HTTP client with timeout
client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
// Authentication provider // Authentication provider
client.WithAuthProvider(client.NewStaticTokenProvider(token)), v2.WithAuthProvider(v2.NewStaticTokenProvider(token)),
// Retry configuration // Retry configuration
client.WithRetryOptions(client.RetryOptions{ v2.WithRetryOptions(v2.RetryOptions{
MaxRetries: 5, MaxRetries: 5,
InitialDelay: 1 * time.Second, InitialDelay: 1 * time.Second,
MaxDelay: 30 * time.Second, MaxDelay: 30 * time.Second,
}), }),
// Request logging // 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: Uses the existing `/api/v1/login` endpoint with automatic token caching:
```go ```go
client := client.NewClientWithCredentials(baseURL, username, password) client := v2.NewClientWithCredentials(baseURL, username, password)
``` ```
**Features:** **Features:**
@ -154,23 +154,23 @@ client := client.NewClientWithCredentials(baseURL, username, password)
For pre-obtained tokens: For pre-obtained tokens:
```go ```go
client := client.NewClient(baseURL, client := v2.NewClient(baseURL,
client.WithAuthProvider(client.NewStaticTokenProvider(token))) v2.WithAuthProvider(v2.NewStaticTokenProvider(token)))
``` ```
## Error Handling ## Error Handling
```go ```go
app, err := client.ShowApp(ctx, appKey, region) app, err := v2.ShowApp(ctx, appKey, region)
if err != nil { if err != nil {
// Check for specific error types // Check for specific error types
if errors.Is(err, client.ErrResourceNotFound) { if errors.Is(err, v2.ErrResourceNotFound) {
fmt.Println("App not found") fmt.Println("App not found")
return return
} }
// Check for API errors // Check for API errors
var apiErr *client.APIError var apiErr *v2.APIError
if errors.As(err, &apiErr) { if errors.As(err, &apiErr) {
fmt.Printf("API Error %d: %s\n", apiErr.StatusCode, apiErr.Messages[0]) fmt.Printf("API Error %d: %s\n", apiErr.StatusCode, apiErr.Messages[0])
return return
@ -213,13 +213,13 @@ The SDK provides a drop-in replacement with enhanced features:
```go ```go
// Old approach // Old approach
oldClient := &client.EdgeConnect{ oldClient := &v2.EdgeConnect{
BaseURL: baseURL, BaseURL: baseURL,
Credentials: client.Credentials{Username: user, Password: pass}, Credentials: v2.Credentials{Username: user, Password: pass},
} }
// New SDK approach // New SDK approach
newClient := client.NewClientWithCredentials(baseURL, user, pass) newClient := v2.NewClientWithCredentials(baseURL, user, pass)
// Same method calls, enhanced reliability // Same method calls, enhanced reliability
err := newClient.CreateApp(ctx, input) err := newClient.CreateApp(ctx, input)

View file

@ -4,11 +4,9 @@
package edgeconnect package edgeconnect
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/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 return nil
} }
// DeleteAppInstance removes an application instance // DeleteAppInstance removes an application instance from the specified region
// Maps to POST /auth/ctrl/DeleteAppInst // Maps to POST /auth/ctrl/DeleteAppInst
func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error { func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error {
transport := c.getTransport() transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst" url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst"
input := DeleteAppInstanceInput{ filter := AppInstanceFilter{
Key: appInstKey, 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 { if err != nil {
return fmt.Errorf("DeleteAppInstance failed: %w", err) 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 // parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances
func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error { 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 appInstances []AppInstance
var messages []string var messages []string
var hasError bool var hasError bool
var errorCode int var errorCode int
var errorMessage string 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) // Try parsing as ResultResponse first (error format)
var resultResp ResultResponse var resultResp ResultResponse
if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" { if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" {

View file

@ -4,7 +4,6 @@
package edgeconnect package edgeconnect
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -143,12 +142,12 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er
transport := c.getTransport() transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp" url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp"
input := DeleteAppInput{ filter := AppFilter{
Key: appKey, App: App{Key: appKey},
Region: region, Region: region,
} }
resp, err := transport.Call(ctx, "POST", url, input) resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil { if err != nil {
return fmt.Errorf("DeleteApp failed: %w", err) 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 // parseStreamingResponse parses the EdgeXR streaming JSON response format
func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error { 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 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] var response Response[App]
if err := json.Unmarshal(line, &response); err != nil { if err := json.Unmarshal(line, &response); err != nil {
return err return err
@ -201,6 +182,9 @@ func (c *Client) parseStreamingResponse(resp *http.Response, result interface{})
} }
// Extract data from responses // Extract data from responses
var apps []App
var messages []string
for _, response := range responses { for _, response := range responses {
if response.HasData() { if response.HasData() {
apps = append(apps, response.Data) apps = append(apps, response.Data)

View file

@ -184,33 +184,24 @@ type App struct {
Deployment string `json:"deployment,omitempty"` Deployment string `json:"deployment,omitempty"`
ImageType string `json:"image_type,omitempty"` ImageType string `json:"image_type,omitempty"`
ImagePath string `json:"image_path,omitempty"` ImagePath string `json:"image_path,omitempty"`
AccessPorts string `json:"access_ports,omitempty"`
AllowServerless bool `json:"allow_serverless,omitempty"` AllowServerless bool `json:"allow_serverless,omitempty"`
DefaultFlavor Flavor `json:"defaultFlavor,omitempty"` DefaultFlavor Flavor `json:"defaultFlavor,omitempty"`
ServerlessConfig interface{} `json:"serverless_config,omitempty"` ServerlessConfig interface{} `json:"serverless_config,omitempty"`
DeploymentGenerator string `json:"deployment_generator,omitempty"` DeploymentGenerator string `json:"deployment_generator,omitempty"`
DeploymentManifest string `json:"deployment_manifest,omitempty"` DeploymentManifest string `json:"deployment_manifest,omitempty"`
RequiredOutboundConnections []SecurityRule `json:"required_outbound_connections"` 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"` Fields []string `json:"fields,omitempty"`
} }
// AppInstance represents a deployed application instance // AppInstance represents a deployed application instance
type AppInstance struct { type AppInstance struct {
msg `json:",inline"` msg `json:",inline"`
Key AppInstanceKey `json:"key"` Key AppInstanceKey `json:"key"`
AppKey AppKey `json:"app_key,omitempty"` AppKey AppKey `json:"app_key,omitempty"`
CloudletLoc CloudletLoc `json:"cloudlet_loc,omitempty"` Flavor Flavor `json:"flavor,omitempty"`
Flavor Flavor `json:"flavor,omitempty"` State string `json:"state,omitempty"`
State string `json:"state,omitempty"` PowerState string `json:"power_state,omitempty"`
IngressURL string `json:"ingress_url,omitempty"` Fields []string `json:"fields,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 // Cloudlet represents edge infrastructure
@ -233,12 +224,6 @@ type Location struct {
Longitude float64 `json:"longitude"` 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 // Input types for API operations
// NewAppInput represents input for creating an application // NewAppInput represents input for creating an application
@ -271,17 +256,6 @@ type UpdateAppInstanceInput struct {
AppInst AppInstance `json:"appinst"` 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 wrapper types
// Response wraps a single API response // Response wraps a single API response

View file

@ -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
}

View file

@ -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)
}
})
}
}

267
sdk/edgeconnect/v2/apps.go Normal file
View file

@ -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,
}
}

View file

@ -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"))
}
}))
}

184
sdk/edgeconnect/v2/auth.go Normal file
View file

@ -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
}

View file

@ -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)
}

View file

@ -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...)
}
}

View file

@ -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
}

View file

@ -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")
}
})
}
}

407
sdk/edgeconnect/v2/types.go Normal file
View file

@ -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"`
}

View file

@ -12,7 +12,7 @@ import (
"strings" "strings"
"time" "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() { func main() {
@ -24,20 +24,20 @@ func main() {
username := getEnvOrDefault("EDGEXR_USERNAME", "") username := getEnvOrDefault("EDGEXR_USERNAME", "")
password := getEnvOrDefault("EDGEXR_PASSWORD", "") password := getEnvOrDefault("EDGEXR_PASSWORD", "")
var client *edgeconnect.Client var client *v2.Client
if token != "" { if token != "" {
fmt.Println("🔐 Using Bearer token authentication") fmt.Println("🔐 Using Bearer token authentication")
client = edgeconnect.NewClient(baseURL, client = v2.NewClient(baseURL,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), v2.WithAuthProvider(v2.NewStaticTokenProvider(token)),
edgeconnect.WithLogger(log.Default()), v2.WithLogger(log.Default()),
) )
} else if username != "" && password != "" { } else if username != "" && password != "" {
fmt.Println("🔐 Using username/password authentication") fmt.Println("🔐 Using username/password authentication")
client = edgeconnect.NewClientWithCredentials(baseURL, username, password, client = v2.NewClientWithCredentials(baseURL, username, password,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect.WithLogger(log.Default()), v2.WithLogger(log.Default()),
) )
} else { } else {
log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD") log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD")
@ -85,15 +85,15 @@ type WorkflowConfig struct {
FlavorName string 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 ═══") fmt.Println("═══ Phase 1: Application Management ═══")
// 1. Create Application // 1. Create Application
fmt.Println("\n1⃣ Creating application...") fmt.Println("\n1⃣ Creating application...")
app := &edgeconnect.NewAppInput{ app := &v2.NewAppInput{
Region: config.Region, Region: config.Region,
App: edgeconnect.App{ App: v2.App{
Key: edgeconnect.AppKey{ Key: v2.AppKey{
Organization: config.Organization, Organization: config.Organization,
Name: config.AppName, Name: config.AppName,
Version: config.AppVersion, Version: config.AppVersion,
@ -101,10 +101,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
Deployment: "kubernetes", Deployment: "kubernetes",
ImageType: "ImageTypeDocker", // field is ignored ImageType: "ImageTypeDocker", // field is ignored
ImagePath: "https://registry-1.docker.io/library/nginx:latest", // must be set. Even for kubernetes 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 ServerlessConfig: struct{}{}, // must be set
AllowServerless: true, // must be set to true for kubernetes AllowServerless: true, // must be set to true for kubernetes
RequiredOutboundConnections: []edgeconnect.SecurityRule{ RequiredOutboundConnections: []v2.SecurityRule{
{ {
Protocol: "tcp", Protocol: "tcp",
PortRangeMin: 80, PortRangeMin: 80,
@ -128,7 +128,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
// 2. Show Application Details // 2. Show Application Details
fmt.Println("\n2⃣ Querying application details...") fmt.Println("\n2⃣ Querying application details...")
appKey := edgeconnect.AppKey{ appKey := v2.AppKey{
Organization: config.Organization, Organization: config.Organization,
Name: config.AppName, Name: config.AppName,
Version: config.AppVersion, Version: config.AppVersion,
@ -146,7 +146,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
// 3. List Applications in Organization // 3. List Applications in Organization
fmt.Println("\n3⃣ Listing 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) apps, err := c.ShowApps(ctx, filter, config.Region)
if err != nil { if err != nil {
return fmt.Errorf("failed to list apps: %w", err) 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 // 4. Create Application Instance
fmt.Println("\n4⃣ Creating application instance...") fmt.Println("\n4⃣ Creating application instance...")
instance := &edgeconnect.NewAppInstanceInput{ instance := &v2.NewAppInstanceInput{
Region: config.Region, Region: config.Region,
AppInst: edgeconnect.AppInstance{ AppInst: v2.AppInstance{
Key: edgeconnect.AppInstanceKey{ Key: v2.AppInstanceKey{
Organization: config.Organization, Organization: config.Organization,
Name: config.InstanceName, Name: config.InstanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: v2.CloudletKey{
Organization: config.CloudletOrg, Organization: config.CloudletOrg,
Name: config.CloudletName, Name: config.CloudletName,
}, },
}, },
AppKey: appKey, 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 // 5. Wait for Application Instance to be Ready
fmt.Println("\n5⃣ Waiting 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, Organization: config.Organization,
Name: config.InstanceName, Name: config.InstanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: v2.CloudletKey{
Organization: config.CloudletOrg, Organization: config.CloudletOrg,
Name: config.CloudletName, Name: config.CloudletName,
}, },
@ -207,7 +207,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
// 6. List Application Instances // 6. List Application Instances
fmt.Println("\n6⃣ Listing 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 { if err != nil {
return fmt.Errorf("failed to list app instances: %w", err) 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 // 8. Show Cloudlet Details
fmt.Println("\n8⃣ Querying cloudlet information...") fmt.Println("\n8⃣ Querying cloudlet information...")
cloudletKey := edgeconnect.CloudletKey{ cloudletKey := v2.CloudletKey{
Organization: config.CloudletOrg, Organization: config.CloudletOrg,
Name: config.CloudletName, Name: config.CloudletName,
} }
@ -287,7 +287,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
// 13. Verify Cleanup // 13. Verify Cleanup
fmt.Println("\n1⃣3⃣ Verifying cleanup...") fmt.Println("\n1⃣3⃣ Verifying cleanup...")
_, err = c.ShowApp(ctx, appKey, config.Region) _, 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") fmt.Printf("✅ Cleanup verified - app no longer exists\n")
} else if err != nil { } else if err != nil {
fmt.Printf("✅ Cleanup appears successful (verification returned: %v)\n", err) 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 // 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) timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
@ -318,7 +318,7 @@ func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKe
for { for {
select { select {
case <-timeoutCtx.Done(): 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: case <-ticker.C:
instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, region) instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, region)

View file

@ -12,7 +12,7 @@ import (
"strings" "strings"
"time" "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() { func main() {
@ -24,22 +24,22 @@ func main() {
username := getEnvOrDefault("EDGEXR_USERNAME", "") username := getEnvOrDefault("EDGEXR_USERNAME", "")
password := getEnvOrDefault("EDGEXR_PASSWORD", "") password := getEnvOrDefault("EDGEXR_PASSWORD", "")
var edgeClient *edgeconnect.Client var edgeClient *v2.Client
if token != "" { if token != "" {
// Use static token authentication // Use static token authentication
fmt.Println("🔐 Using Bearer token authentication") fmt.Println("🔐 Using Bearer token authentication")
edgeClient = edgeconnect.NewClient(baseURL, edgeClient = v2.NewClient(baseURL,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), v2.WithAuthProvider(v2.NewStaticTokenProvider(token)),
edgeconnect.WithLogger(log.Default()), v2.WithLogger(log.Default()),
) )
} else if username != "" && password != "" { } else if username != "" && password != "" {
// Use username/password authentication (matches existing client pattern) // Use username/password authentication (matches existing client pattern)
fmt.Println("🔐 Using username/password authentication") fmt.Println("🔐 Using username/password authentication")
edgeClient = edgeconnect.NewClientWithCredentials(baseURL, username, password, edgeClient = v2.NewClientWithCredentials(baseURL, username, password,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect.WithLogger(log.Default()), v2.WithLogger(log.Default()),
) )
} else { } else {
log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD") log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD")
@ -48,10 +48,10 @@ func main() {
ctx := context.Background() ctx := context.Background()
// Example application to deploy // Example application to deploy
app := &edgeconnect.NewAppInput{ app := &v2.NewAppInput{
Region: "EU", Region: "EU",
App: edgeconnect.App{ App: v2.App{
Key: edgeconnect.AppKey{ Key: v2.AppKey{
Organization: "edp2", Organization: "edp2",
Name: "my-edge-app", Name: "my-edge-app",
Version: "1.0.0", Version: "1.0.0",
@ -59,7 +59,7 @@ func main() {
Deployment: "docker", Deployment: "docker",
ImageType: "ImageTypeDocker", ImageType: "ImageTypeDocker",
ImagePath: "https://registry-1.docker.io/library/nginx:latest", ImagePath: "https://registry-1.docker.io/library/nginx:latest",
DefaultFlavor: edgeconnect.Flavor{Name: "EU.small"}, DefaultFlavor: v2.Flavor{Name: "EU.small"},
ServerlessConfig: struct{}{}, ServerlessConfig: struct{}{},
AllowServerless: false, AllowServerless: false,
}, },
@ -73,7 +73,7 @@ func main() {
fmt.Println("✅ SDK example completed successfully!") 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 appKey := input.App.Key
region := input.Region region := input.Region
@ -98,7 +98,7 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client
// Step 3: List applications in the organization // Step 3: List applications in the organization
fmt.Println("\n3. Listing applications...") fmt.Println("\n3. Listing applications...")
filter := edgeconnect.AppKey{Organization: appKey.Organization} filter := v2.AppKey{Organization: appKey.Organization}
apps, err := edgeClient.ShowApps(ctx, filter, region) apps, err := edgeClient.ShowApps(ctx, filter, region)
if err != nil { if err != nil {
return fmt.Errorf("failed to list apps: %w", err) 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...") fmt.Println("\n5. Verifying deletion...")
_, err = edgeClient.ShowApp(ctx, appKey, region) _, err = edgeClient.ShowApp(ctx, appKey, region)
if err != nil { 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") fmt.Printf("✅ App successfully deleted (not found)\n")
} else { } else {
return fmt.Errorf("unexpected error verifying deletion: %w", err) return fmt.Errorf("unexpected error verifying deletion: %w", err)