diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 22a7486..019aa48 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -1,6 +1,7 @@ package main import ( + "log" "os" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect" @@ -8,30 +9,43 @@ import ( "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/app" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/cloudlet" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/instance" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/organization" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client" ) func main() { - // PrΓ€sentationsschicht: Simple dependency wiring - no complex container needed + // Hexagonal Architecture Wiring - // 1. Infrastructure Layer: Create EdgeConnect client (concrete implementation) + // 1. Infrastructure Layer: Create the low-level EdgeConnect client baseURL := getEnvOrDefault("EDGE_CONNECT_BASE_URL", "https://console.mobiledgex.net") username := os.Getenv("EDGE_CONNECT_USERNAME") password := os.Getenv("EDGE_CONNECT_PASSWORD") - var client *edgeconnect.Client - if username != "" && password != "" { - client = edgeconnect.NewClientWithCredentials(baseURL, username, password) - } else { - client = edgeconnect.NewClient(baseURL) + // Use a logger for the infrastructure client + logger := log.New(os.Stderr, "[edgeconnect-client] ", log.LstdFlags) + clientOpts := []edgeconnect_client.Option{ + edgeconnect_client.WithLogger(logger), } - // 2. Application Layer: Create services with dependency injection (client implements repository interfaces) - appService := app.NewService(client) // client implements AppRepository - instanceService := instance.NewService(client) // client implements AppInstanceRepository - cloudletService := cloudlet.NewService(client) // client implements CloudletRepository + var infraClient *edgeconnect_client.Client + if username != "" && password != "" { + infraClient = edgeconnect_client.NewClientWithCredentials(baseURL, username, password, clientOpts...) + } else { + infraClient = edgeconnect_client.NewClient(baseURL, clientOpts...) + } - // 3. Presentation Layer: Execute CLI driven adapters with injected services (simple parameter passing) - cli.ExecuteWithServices(appService, instanceService, cloudletService) + // 2. Adapter Layer: Create the driven adapter, injecting the infrastructure client. + // This adapter implements the repository interfaces required by the application layer. + edgeConnectAdapter := edgeconnect.NewAdapter(infraClient) + + // 3. Application Layer: Create services, injecting the adapter (which fulfills the repository port). + appService := app.NewService(edgeConnectAdapter) + instanceService := instance.NewService(edgeConnectAdapter) + cloudletService := cloudlet.NewService(edgeConnectAdapter) + organizationService := organization.NewService(edgeConnectAdapter) + + // 4. Driving Adapter (Presentation Layer): Execute the CLI, injecting the application services. + cli.ExecuteWithServices(appService, instanceService, cloudletService, organizationService) } func getEnvOrDefault(key, defaultValue string) string { diff --git a/internal/adapters/driven/edgeconnect/adapter.go b/internal/adapters/driven/edgeconnect/adapter.go new file mode 100644 index 0000000..e07abbc --- /dev/null +++ b/internal/adapters/driven/edgeconnect/adapter.go @@ -0,0 +1,747 @@ +package edgeconnect + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/transport" +) + +// Adapter implements the driven ports for the EdgeConnect API. +// It acts as a bridge between the application's core logic and the +// underlying infrastructure client, translating domain requests into +// infrastructure calls. +type Adapter struct { + client *edgeconnect_client.Client +} + +// NewAdapter creates a new EdgeConnect adapter. +// It requires a configured infrastructure client to communicate with the API. +func NewAdapter(client *edgeconnect_client.Client) *Adapter { + return &Adapter{client: client} +} + +// Ensure the adapter implements all required repository interfaces. +var _ driven.AppRepository = (*Adapter)(nil) +var _ driven.AppInstanceRepository = (*Adapter)(nil) +var _ driven.CloudletRepository = (*Adapter)(nil) +var _ driven.OrganizationRepository = (*Adapter)(nil) + +// OrganizationRepository implementation + +// CreateOrganization creates a new organization. +func (a *Adapter) CreateOrganization(ctx context.Context, org *domain.Organization) error { + apiPath := "/api/v1/auth/org/create" + _, err := a.client.Call(ctx, http.MethodPost, apiPath, org, nil) + if err != nil { + // TODO: Improve error handling to return domain-specific errors + return fmt.Errorf("failed to create organization %s: %w", org.Name, err) + } + a.client.Logf("Successfully created organization: %s", org.Name) + return nil +} + +// ShowOrganization retrieves a single organization by name. +func (a *Adapter) ShowOrganization(ctx context.Context, name string) (*domain.Organization, error) { + apiPath := "/api/v1/auth/org/show" + reqBody := map[string]string{"name": name} + var org domain.Organization + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, reqBody, &org) + if err != nil { + // TODO: Improve error handling, check for 404 and return domain.ErrResourceNotFound + return nil, fmt.Errorf("failed to show organization %s: %w", name, err) + } + return &org, nil +} + +// UpdateOrganization updates an existing organization. +func (a *Adapter) UpdateOrganization(ctx context.Context, org *domain.Organization) error { + apiPath := "/api/v1/auth/org/update" + _, err := a.client.Call(ctx, http.MethodPost, apiPath, org, nil) + if err != nil { + return fmt.Errorf("failed to update organization %s: %w", org.Name, err) + } + a.client.Logf("Successfully updated organization: %s", org.Name) + return nil +} + +// DeleteOrganization deletes an organization by name. +func (a *Adapter) DeleteOrganization(ctx context.Context, name string) error { + apiPath := "/api/v1/auth/org/delete" + reqBody := map[string]string{"name": name} + + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, reqBody, nil) + if err != nil { + // A 404 status is acceptable, means it's already deleted. + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + a.client.Logf("Organization %s not found for deletion, considered successful.", name) + return nil + } + return fmt.Errorf("failed to delete organization %s: %w", name, err) + } + // The Call method now handles the response body closure if result is not nil. + // If result is nil, we must close it. + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + + a.client.Logf("Successfully deleted organization: %s", name) + return nil +} + +// AppRepository implementation + +// CreateApp creates a new application. +func (a *Adapter) CreateApp(ctx context.Context, region string, app *domain.App) error { + apiPath := "/api/v1/auth/ctrl/CreateApp" + apiApp := toAPIApp(app) + input := &edgeconnect_client.NewAppInput{ + Region: region, + App: *apiApp, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil) + if err != nil { + return fmt.Errorf("CreateApp failed: %w", err) + } + a.client.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. +func (a *Adapter) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) { + apiPath := "/api/v1/auth/ctrl/ShowApp" + apiAppKey := toAPIAppKey(appKey) + filter := edgeconnect_client.AppFilter{ + App: edgeconnect_client.App{Key: *apiAppKey}, + Region: region, + } + + var apps []edgeconnect_client.App + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, "application not found") + } + return nil, fmt.Errorf("ShowApp failed: %w", err) + } + defer resp.Body.Close() + + if err := parseStreamingResponse(resp, &apps); err != nil { + return nil, fmt.Errorf("ShowApp failed to parse response: %w", err) + } + + if len(apps) == 0 { + return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, "application not found") + } + + domainApp := toDomainApp(&apps[0]) + return &domainApp, nil +} + +// ShowApps retrieves all applications matching the filter. +func (a *Adapter) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) { + apiPath := "/api/v1/auth/ctrl/ShowApp" + apiAppKey := toAPIAppKey(appKey) + filter := edgeconnect_client.AppFilter{ + App: edgeconnect_client.App{Key: *apiAppKey}, + Region: region, + } + + var apiApps []edgeconnect_client.App + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return []domain.App{}, nil // Return empty slice for not found + } + return nil, fmt.Errorf("ShowApps failed: %w", err) + } + defer resp.Body.Close() + + if err := parseStreamingResponse(resp, &apiApps); err != nil { + return nil, fmt.Errorf("ShowApps failed to parse response: %w", err) + } + + a.client.Logf("ShowApps: found %d apps matching criteria", len(apiApps)) + domainApps := make([]domain.App, len(apiApps)) + for i := range apiApps { + domainApps[i] = toDomainApp(&apiApps[i]) + } + return domainApps, nil +} + +// UpdateApp updates an existing application. +func (a *Adapter) UpdateApp(ctx context.Context, region string, app *domain.App) error { + apiPath := "/api/v1/auth/ctrl/UpdateApp" + apiApp := toAPIApp(app) + input := &edgeconnect_client.UpdateAppInput{ + Region: region, + App: *apiApp, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil) + if err != nil { + return fmt.Errorf("UpdateApp failed: %w", err) + } + a.client.Logf("UpdateApp: %s/%s version %s updated successfully", + input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version) + return nil +} + +// DeleteApp deletes an application. +func (a *Adapter) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error { + apiPath := "/api/v1/auth/ctrl/DeleteApp" + apiAppKey := toAPIAppKey(appKey) + filter := edgeconnect_client.AppFilter{ + App: edgeconnect_client.App{Key: *apiAppKey}, + Region: region, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + a.client.Logf("App %v not found for deletion, considered successful.", appKey) + return nil + } + return fmt.Errorf("DeleteApp failed: %w", err) + } + a.client.Logf("DeleteApp: %s/%s version %s deleted successfully", + appKey.Organization, appKey.Name, appKey.Version) + return nil +} + +// AppInstanceRepository implementation + +// CreateAppInstance creates a new application instance. +func (a *Adapter) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + apiPath := "/api/v1/auth/ctrl/CreateAppInst" + apiAppInst := toAPIAppInstance(appInst) + input := &edgeconnect_client.NewAppInstanceInput{ + Region: region, + AppInst: *apiAppInst, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil) + if err != nil { + return fmt.Errorf("CreateAppInstance failed: %w", err) + } + a.client.Logf("CreateAppInstance: %s/%s created successfully", + input.AppInst.Key.Organization, input.AppInst.Key.Name) + return nil +} + +// ShowAppInstance retrieves a single application instance. +func (a *Adapter) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) { + apiPath := "/api/v1/auth/ctrl/ShowAppInst" + apiAppInstKey := toAPIAppInstanceKey(appInstKey) + filter := edgeconnect_client.AppInstanceFilter{ + AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey}, + Region: region, + } + + var appInstances []edgeconnect_client.AppInstance + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, "app instance not found") + } + return nil, fmt.Errorf("ShowAppInstance failed: %w", err) + } + defer resp.Body.Close() + + if err := parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + return nil, fmt.Errorf("ShowAppInstance failed to parse response: %w", err) + } + + if len(appInstances) == 0 { + return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, "app instance not found") + } + + domainAppInst := toDomainAppInstance(&appInstances[0]) + return &domainAppInst, nil +} + +// ShowAppInstances retrieves all application instances matching the filter. +func (a *Adapter) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) { + apiPath := "/api/v1/auth/ctrl/ShowAppInst" + apiAppInstKey := toAPIAppInstanceKey(appInstKey) + filter := edgeconnect_client.AppInstanceFilter{ + AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey}, + Region: region, + } + + var appInstances []edgeconnect_client.AppInstance + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return []domain.AppInstance{}, nil + } + return nil, fmt.Errorf("ShowAppInstances failed: %w", err) + } + defer resp.Body.Close() + + if err := parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { + return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err) + } + + a.client.Logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances)) + domainAppInsts := make([]domain.AppInstance, len(appInstances)) + for i := range appInstances { + domainAppInsts[i] = toDomainAppInstance(&appInstances[i]) + } + return domainAppInsts, nil +} + +// UpdateAppInstance updates an existing application instance. +func (a *Adapter) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { + apiPath := "/api/v1/auth/ctrl/UpdateAppInst" + apiAppInst := toAPIAppInstance(appInst) + input := &edgeconnect_client.UpdateAppInstanceInput{ + Region: region, + AppInst: *apiAppInst, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil) + if err != nil { + return fmt.Errorf("UpdateAppInstance failed: %w", err) + } + a.client.Logf("UpdateAppInstance: %s/%s updated successfully", + input.AppInst.Key.Organization, input.AppInst.Key.Name) + return nil +} + +// RefreshAppInstance refreshes an application instance. +func (a *Adapter) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + apiPath := "/api/v1/auth/ctrl/RefreshAppInst" + apiAppInstKey := toAPIAppInstanceKey(appInstKey) + filter := edgeconnect_client.AppInstanceFilter{ + AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey}, + Region: region, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + return fmt.Errorf("RefreshAppInstance failed: %w", err) + } + a.client.Logf("RefreshAppInstance: %s/%s refreshed successfully", + appInstKey.Organization, appInstKey.Name) + return nil +} + +// DeleteAppInstance deletes an application instance. +func (a *Adapter) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { + apiPath := "/api/v1/auth/ctrl/DeleteAppInst" + apiAppInstKey := toAPIAppInstanceKey(appInstKey) + filter := edgeconnect_client.AppInstanceFilter{ + AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey}, + Region: region, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + a.client.Logf("AppInstance %v not found for deletion, considered successful.", appInstKey) + return nil + } + return fmt.Errorf("DeleteAppInstance failed: %w", err) + } + a.client.Logf("DeleteAppInstance: %s/%s deleted successfully", + appInstKey.Organization, appInstKey.Name) + return nil +} + +// CloudletRepository implementation + +// CreateCloudlet creates a new cloudlet. +func (a *Adapter) CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error { + apiPath := "/api/v1/auth/ctrl/CreateCloudlet" + apiCloudlet := toAPICloudlet(cloudlet) + input := &edgeconnect_client.NewCloudletInput{ + Region: region, + Cloudlet: *apiCloudlet, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil) + if err != nil { + return fmt.Errorf("CreateCloudlet failed: %w", err) + } + a.client.Logf("CreateCloudlet: %s/%s created successfully", + input.Cloudlet.Key.Organization, input.Cloudlet.Key.Name) + return nil +} + +// ShowCloudlet retrieves a single cloudlet. +func (a *Adapter) ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) { + apiPath := "/api/v1/auth/ctrl/ShowCloudlet" + apiCloudletKey := toAPICloudletKey(cloudletKey) + filter := edgeconnect_client.CloudletFilter{ + Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey}, + Region: region, + } + + var cloudlets []edgeconnect_client.Cloudlet + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, "cloudlet not found") + } + return nil, fmt.Errorf("ShowCloudlet failed: %w", err) + } + defer resp.Body.Close() + + if err := parseStreamingCloudletResponse(resp, &cloudlets); err != nil { + return nil, fmt.Errorf("ShowCloudlet failed to parse response: %w", err) + } + + if len(cloudlets) == 0 { + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, "cloudlet not found") + } + + domainCloudlet := toDomainCloudlet(&cloudlets[0]) + return &domainCloudlet, nil +} + +// ShowCloudlets retrieves all cloudlets matching the filter. +func (a *Adapter) ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) { + apiPath := "/api/v1/auth/ctrl/ShowCloudlet" + apiCloudletKey := toAPICloudletKey(cloudletKey) + filter := edgeconnect_client.CloudletFilter{ + Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey}, + Region: region, + } + + var cloudlets []edgeconnect_client.Cloudlet + resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return []domain.Cloudlet{}, nil + } + return nil, fmt.Errorf("ShowCloudlets failed: %w", err) + } + defer resp.Body.Close() + + if err := parseStreamingCloudletResponse(resp, &cloudlets); err != nil { + return nil, fmt.Errorf("ShowCloudlets failed to parse response: %w", err) + } + + a.client.Logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets)) + domainCloudlets := make([]domain.Cloudlet, len(cloudlets)) + for i := range cloudlets { + domainCloudlets[i] = toDomainCloudlet(&cloudlets[i]) + } + return domainCloudlets, nil +} + +// DeleteCloudlet deletes a cloudlet. +func (a *Adapter) DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error { + apiPath := "/api/v1/auth/ctrl/DeleteCloudlet" + apiCloudletKey := toAPICloudletKey(cloudletKey) + filter := edgeconnect_client.CloudletFilter{ + Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey}, + Region: region, + } + + _, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + a.client.Logf("Cloudlet %v not found for deletion, considered successful.", cloudletKey) + return nil + } + return fmt.Errorf("DeleteCloudlet failed: %w", err) + } + a.client.Logf("DeleteCloudlet: %s/%s deleted successfully", + cloudletKey.Organization, cloudletKey.Name) + return nil +} + +// GetCloudletManifest retrieves the deployment manifest for a cloudlet. +func (a *Adapter) GetCloudletManifest(ctx context.Context, cloudletKey domain.CloudletKey, region string) (*edgeconnect_client.CloudletManifest, error) { + apiPath := "/api/v1/auth/ctrl/GetCloudletManifest" + apiCloudletKey := toAPICloudletKey(cloudletKey) + filter := edgeconnect_client.CloudletFilter{ + Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey}, + Region: region, + } + + var manifest edgeconnect_client.CloudletManifest + _, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, &manifest) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "GetCloudletManifest", cloudletKey, region, "cloudlet manifest not found") + } + return nil, fmt.Errorf("GetCloudletManifest failed: %w", err) + } + a.client.Logf("GetCloudletManifest: retrieved manifest for %s/%s", + cloudletKey.Organization, cloudletKey.Name) + return &manifest, nil +} + +// GetCloudletResourceUsage retrieves resource usage for a cloudlet. +func (a *Adapter) GetCloudletResourceUsage(ctx context.Context, cloudletKey domain.CloudletKey, region string) (*edgeconnect_client.CloudletResourceUsage, error) { + apiPath := "/api/v1/auth/ctrl/GetCloudletResourceUsage" + apiCloudletKey := toAPICloudletKey(cloudletKey) + filter := edgeconnect_client.CloudletFilter{ + Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey}, + Region: region, + } + + var usage edgeconnect_client.CloudletResourceUsage + _, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, &usage) + if err != nil { + if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "GetCloudletResourceUsage", cloudletKey, region, "cloudlet resource usage not found") + } + return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err) + } + a.client.Logf("GetCloudletResourceUsage: retrieved usage for %s/%s", + cloudletKey.Organization, cloudletKey.Name) + return &usage, nil +} + +// Helper functions for parsing streaming responses + +func parseStreamingResponse(resp *http.Response, result interface{}) error { + var dataItems []json.RawMessage + var messages []string + + parseErr := transport.ParseJSONLines(resp.Body, func(line []byte) error { + // Try to unmarshal into a message structure first + var msg struct { + Result struct { + Message string `json:"message"` + } `json:"result"` + } + if err := json.Unmarshal(line, &msg); err == nil && msg.Result.Message != "" { + messages = append(messages, msg.Result.Message) + return nil + } + + // If it's not a message, assume it's a data object + var data struct { + Result json.RawMessage `json:"result"` + } + if err := json.Unmarshal(line, &data); err != nil { + // If we can't even unmarshal it into this basic struct, it's a problem + return fmt.Errorf("failed to unmarshal streaming line: %w", err) + } + dataItems = append(dataItems, data.Result) + return nil + }) + + if parseErr != nil { + return parseErr + } + + if len(messages) > 0 && len(dataItems) == 0 { + // If we only got messages and no data, it's likely an error response + return &edgeconnect_client.APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + } + } + + // Re-marshal the collected data items and unmarshal into the final result slice + dataBytes, err := json.Marshal(dataItems) + if err != nil { + return fmt.Errorf("failed to re-marshal data items: %w", err) + } + + if err := json.Unmarshal(dataBytes, result); err != nil { + return fmt.Errorf("failed to unmarshal data into result: %w", err) + } + + return nil +} + +func parseStreamingAppInstanceResponse(resp *http.Response, result *[]edgeconnect_client.AppInstance) error { + return parseStreamingResponse(resp, result) +} + +func parseStreamingCloudletResponse(resp *http.Response, result *[]edgeconnect_client.Cloudlet) error { + return parseStreamingResponse(resp, result) +} + +// Data mapping functions (domain <-> API) + +func toAPIApp(app *domain.App) *edgeconnect_client.App { + return &edgeconnect_client.App{ + Key: *toAPIAppKey(app.Key), + Deployment: app.Deployment, + ImageType: app.ImageType, + ImagePath: app.ImagePath, + AllowServerless: app.AllowServerless, + DefaultFlavor: toAPIFlavor(app.DefaultFlavor), + ServerlessConfig: app.ServerlessConfig, + DeploymentGenerator: app.DeploymentGenerator, + DeploymentManifest: app.DeploymentManifest, + RequiredOutboundConnections: toAPISecurityRules(app.RequiredOutboundConnections), + Fields: app.Fields, + } +} + +func toDomainApp(app *edgeconnect_client.App) domain.App { + return domain.App{ + Key: toDomainAppKey(app.Key), + Deployment: app.Deployment, + ImageType: app.ImageType, + ImagePath: app.ImagePath, + AllowServerless: app.AllowServerless, + DefaultFlavor: toDomainFlavor(app.DefaultFlavor), + ServerlessConfig: app.ServerlessConfig, + DeploymentGenerator: app.DeploymentGenerator, + DeploymentManifest: app.DeploymentManifest, + RequiredOutboundConnections: toDomainSecurityRules(app.RequiredOutboundConnections), + Fields: app.Fields, + } +} + +func toAPIAppKey(appKey domain.AppKey) *edgeconnect_client.AppKey { + return &edgeconnect_client.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } +} + +func toDomainAppKey(appKey edgeconnect_client.AppKey) domain.AppKey { + return domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + } +} + +func toAPIFlavor(flavor domain.Flavor) edgeconnect_client.Flavor { + return edgeconnect_client.Flavor{Name: flavor.Name} +} + +func toDomainFlavor(flavor edgeconnect_client.Flavor) domain.Flavor { + return domain.Flavor{Name: flavor.Name} +} + +func toAPISecurityRules(rules []domain.SecurityRule) []edgeconnect_client.SecurityRule { + apiRules := make([]edgeconnect_client.SecurityRule, len(rules)) + for i, r := range rules { + apiRules[i] = edgeconnect_client.SecurityRule{ + PortRangeMax: r.PortRangeMax, + PortRangeMin: r.PortRangeMin, + Protocol: r.Protocol, + RemoteCIDR: r.RemoteCIDR, + } + } + return apiRules +} + +func toDomainSecurityRules(rules []edgeconnect_client.SecurityRule) []domain.SecurityRule { + domainRules := make([]domain.SecurityRule, len(rules)) + for i, r := range rules { + domainRules[i] = domain.SecurityRule{ + PortRangeMax: r.PortRangeMax, + PortRangeMin: r.PortRangeMin, + Protocol: r.Protocol, + RemoteCIDR: r.RemoteCIDR, + } + } + return domainRules +} + +func toAPIAppInstance(appInst *domain.AppInstance) *edgeconnect_client.AppInstance { + return &edgeconnect_client.AppInstance{ + Key: *toAPIAppInstanceKey(appInst.Key), + AppKey: *toAPIAppKey(appInst.AppKey), + Flavor: toAPIFlavor(appInst.Flavor), + State: appInst.State, + PowerState: appInst.PowerState, + Fields: appInst.Fields, + } +} + +func toDomainAppInstance(appInst *edgeconnect_client.AppInstance) domain.AppInstance { + return domain.AppInstance{ + Key: toDomainAppInstanceKey(appInst.Key), + AppKey: toDomainAppKey(appInst.AppKey), + Flavor: toDomainFlavor(appInst.Flavor), + State: appInst.State, + PowerState: appInst.PowerState, + Fields: appInst.Fields, + } +} + +func toAPIAppInstanceKey(key domain.AppInstanceKey) *edgeconnect_client.AppInstanceKey { + return &edgeconnect_client.AppInstanceKey{ + Organization: key.Organization, + Name: key.Name, + CloudletKey: toAPICloudletKey(key.CloudletKey), + } +} + +func toDomainAppInstanceKey(key edgeconnect_client.AppInstanceKey) domain.AppInstanceKey { + return domain.AppInstanceKey{ + Organization: key.Organization, + Name: key.Name, + CloudletKey: toDomainCloudletKey(key.CloudletKey), + } +} + +func toAPICloudletKey(key domain.CloudletKey) edgeconnect_client.CloudletKey { + return edgeconnect_client.CloudletKey{ + Organization: key.Organization, + Name: key.Name, + } +} + +func toDomainCloudletKey(key edgeconnect_client.CloudletKey) domain.CloudletKey { + return domain.CloudletKey{ + Organization: key.Organization, + Name: key.Name, + } +} + +func toAPICloudlet(cloudlet *domain.Cloudlet) *edgeconnect_client.Cloudlet { + return &edgeconnect_client.Cloudlet{ + Key: toAPICloudletKey(cloudlet.Key), + Location: toAPILocation(cloudlet.Location), + IpSupport: cloudlet.IpSupport, + NumDynamicIps: cloudlet.NumDynamicIps, + State: cloudlet.State, + Flavor: toAPIFlavor(cloudlet.Flavor), + PhysicalName: cloudlet.PhysicalName, + Region: cloudlet.Region, + NotifySrvAddr: cloudlet.NotifySrvAddr, + } +} + +func toDomainCloudlet(cloudlet *edgeconnect_client.Cloudlet) domain.Cloudlet { + return domain.Cloudlet{ + Key: toDomainCloudletKey(cloudlet.Key), + Location: toDomainLocation(cloudlet.Location), + IpSupport: cloudlet.IpSupport, + NumDynamicIps: cloudlet.NumDynamicIps, + State: cloudlet.State, + Flavor: toDomainFlavor(cloudlet.Flavor), + PhysicalName: cloudlet.PhysicalName, + Region: cloudlet.Region, + NotifySrvAddr: cloudlet.NotifySrvAddr, + } +} + +func toAPILocation(location domain.Location) edgeconnect_client.Location { + return edgeconnect_client.Location{ + Latitude: location.Latitude, + Longitude: location.Longitude, + } +} + +func toDomainLocation(location edgeconnect_client.Location) domain.Location { + return domain.Location{ + Latitude: location.Latitude, + Longitude: location.Longitude, + } +} diff --git a/internal/adapters/driven/edgeconnect/appinstance.go b/internal/adapters/driven/edgeconnect/appinstance.go deleted file mode 100644 index 8767b4e..0000000 --- a/internal/adapters/driven/edgeconnect/appinstance.go +++ /dev/null @@ -1,333 +0,0 @@ -// ABOUTME: Application instance lifecycle management APIs for EdgeXR Master Controller -// ABOUTME: Provides typed methods for creating, querying, and deleting application instances - -package edgeconnect - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/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, region string, appInst *domain.AppInstance) error { - transport := c.getTransport() - apiAppInst := ToAPIAppInstance(appInst) - input := &NewAppInstanceInput{ - Region: region, - AppInst: *apiAppInst, - } - url := c.BaseURL + "/api/v1/auth/ctrl/CreateAppInst" - - resp, err := transport.Call(ctx, "POST", url, input) - if err != nil { - return fmt.Errorf("CreateAppInstance failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - if resp.StatusCode >= 400 { - return c.handleErrorResponse(resp, "CreateAppInstance") - } - - 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, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" - - apiAppInstKey := toAPIAppInstanceKey(appInstKey) - filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: *apiAppInstKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("ShowAppInstance failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - if resp.StatusCode == http.StatusNotFound { - return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, - "app instance not found") - } - - if resp.StatusCode >= 400 { - return nil, c.handleErrorResponse(resp, "ShowAppInstance") - } - - // Parse streaming JSON response - var appInstances []AppInstance - if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { - return nil, fmt.Errorf("ShowAppInstance failed to parse response: %w", err) - } - - if len(appInstances) == 0 { - return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, - "app instance not found") - } - - domainAppInst := toDomainAppInstance(&appInstances[0]) - return &domainAppInst, nil -} - -// ShowAppInstances retrieves all application instances matching the filter criteria -// Maps to POST /auth/ctrl/ShowAppInst -func (c *Client) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" - - apiAppInstKey := toAPIAppInstanceKey(appInstKey) - filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: *apiAppInstKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("ShowAppInstances failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { - return nil, c.handleErrorResponse(resp, "ShowAppInstances") - } - - var appInstances []AppInstance - if resp.StatusCode == http.StatusNotFound { - return []domain.AppInstance{}, 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)) - - domainAppInsts := make([]domain.AppInstance, len(appInstances)) - for i := range appInstances { - domainAppInsts[i] = toDomainAppInstance(&appInstances[i]) - } - - return domainAppInsts, nil -} - -// UpdateAppInstance updates an application instance and then refreshes it -// Maps to POST /auth/ctrl/UpdateAppInst -func (c *Client) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/UpdateAppInst" - - apiAppInst := ToAPIAppInstance(appInst) - input := &UpdateAppInstanceInput{ - Region: region, - AppInst: *apiAppInst, - } - resp, err := transport.Call(ctx, "POST", url, input) - if err != nil { - return fmt.Errorf("UpdateAppInstance failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - 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, region string, appInstKey domain.AppInstanceKey) error { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/RefreshAppInst" - - apiAppInstKey := toAPIAppInstanceKey(appInstKey) - filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: *apiAppInstKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return fmt.Errorf("RefreshAppInstance failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - 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 from the specified region -// Maps to POST /auth/ctrl/DeleteAppInst -func (c *Client) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst" - - apiAppInstKey := toAPIAppInstanceKey(appInstKey) - filter := AppInstanceFilter{ - AppInstance: AppInstance{Key: *apiAppInstKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return fmt.Errorf("DeleteAppInstance failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - // 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 { - var responses []Response[AppInstance] - - parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { - var response Response[AppInstance] - 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 appInstances []AppInstance - var messages []string - - for _, response := range responses { - if response.HasData() { - appInstances = append(appInstances, 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 *[]AppInstance: - *v = appInstances - default: - return fmt.Errorf("unsupported result type: %T", result) - } - - return nil -} - -func ToAPIAppInstance(appInst *domain.AppInstance) *AppInstance { - return &AppInstance{ - Key: *toAPIAppInstanceKey(appInst.Key), - AppKey: *toAPIAppKey(appInst.AppKey), - Flavor: toAPIFlavor(appInst.Flavor), - State: appInst.State, - PowerState: appInst.PowerState, - Fields: appInst.Fields, - } -} - -func toDomainAppInstance(appInst *AppInstance) domain.AppInstance { - return domain.AppInstance{ - Key: toDomainAppInstanceKey(appInst.Key), - AppKey: toDomainAppKey(appInst.AppKey), - Flavor: toDomainFlavor(appInst.Flavor), - State: appInst.State, - PowerState: appInst.PowerState, - Fields: appInst.Fields, - } -} - -func toAPIAppInstanceKey(key domain.AppInstanceKey) *AppInstanceKey { - return &AppInstanceKey{ - Organization: key.Organization, - Name: key.Name, - CloudletKey: toAPICloudletKey(key.CloudletKey), - } -} - -func toDomainAppInstanceKey(key AppInstanceKey) domain.AppInstanceKey { - return domain.AppInstanceKey{ - Organization: key.Organization, - Name: key.Name, - CloudletKey: toDomainCloudletKey(key.CloudletKey), - } -} - -func toAPICloudletKey(key domain.CloudletKey) CloudletKey { - return CloudletKey{ - Organization: key.Organization, - Name: key.Name, - } -} - -func toDomainCloudletKey(key CloudletKey) domain.CloudletKey { - return domain.CloudletKey{ - Organization: key.Organization, - Name: key.Name, - } -} \ No newline at end of file diff --git a/internal/adapters/driven/edgeconnect/appinstance_test.go b/internal/adapters/driven/edgeconnect/appinstance_test.go deleted file mode 100644 index 999de39..0000000 --- a/internal/adapters/driven/edgeconnect/appinstance_test.go +++ /dev/null @@ -1,536 +0,0 @@ -// ABOUTME: Unit tests for AppInstance management APIs using httptest mock server -// ABOUTME: Tests create, show, list, refresh, and delete operations with error conditions - -package edgeconnect - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" - "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 - }{ - { - 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, - }, - } - - 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) - if _, err := w.Write([]byte(tt.mockResponse)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - })) - 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() - domainAppInst := &domain.AppInstance{ - Key: domain.AppInstanceKey{ - Organization: tt.input.AppInst.Key.Organization, - Name: tt.input.AppInst.Key.Name, - CloudletKey: domain.CloudletKey{ - Organization: tt.input.AppInst.Key.CloudletKey.Organization, - Name: tt.input.AppInst.Key.CloudletKey.Name, - }, - }, - AppKey: domain.AppKey{ - Organization: tt.input.AppInst.AppKey.Organization, - Name: tt.input.AppInst.AppKey.Name, - Version: tt.input.AppInst.AppKey.Version, - }, - Flavor: domain.Flavor{Name: tt.input.AppInst.Flavor.Name}, - } - err := client.CreateAppInstance(ctx, tt.input.Region, domainAppInst) - - // Verify results - if tt.expectError { - assert.Error(t, err) - } 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 != "" { - if _, err := w.Write([]byte(tt.mockResponse)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - } - })) - defer server.Close() - - // Create client - client := NewClient(server.URL, - WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), - ) - - // Execute test - ctx := context.Background() - domainAppInstKey := domain.AppInstanceKey{ - Organization: tt.appInstKey.Organization, - Name: tt.appInstKey.Name, - CloudletKey: domain.CloudletKey{ - Organization: tt.appInstKey.CloudletKey.Organization, - Name: tt.appInstKey.CloudletKey.Name, - }, - } - appInst, err := client.ShowAppInstance(ctx, tt.region, domainAppInstKey) - - // Verify results - if tt.expectError { - assert.Error(t, err) - if tt.expectNotFound { - assert.True(t, domain.IsNotFoundError(err)) - } - } 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) - if _, err := w.Write([]byte(response)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - domainAppInstKey := domain.AppInstanceKey{Organization: "testorg"} - appInstances, err := client.ShowAppInstances(ctx, "us-west", domainAppInstKey) - - 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) - if _, err := w.Write([]byte(tt.mockResponse)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - })) - 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() - domainAppInst := &domain.AppInstance{ - Key: domain.AppInstanceKey{ - Organization: tt.input.AppInst.Key.Organization, - Name: tt.input.AppInst.Key.Name, - CloudletKey: domain.CloudletKey{ - Organization: tt.input.AppInst.Key.CloudletKey.Organization, - Name: tt.input.AppInst.Key.CloudletKey.Name, - }, - }, - AppKey: domain.AppKey{ - Organization: tt.input.AppInst.AppKey.Organization, - Name: tt.input.AppInst.AppKey.Name, - Version: tt.input.AppInst.AppKey.Version, - }, - Flavor: domain.Flavor{Name: tt.input.AppInst.Flavor.Name}, - PowerState: tt.input.AppInst.PowerState, - } - err := client.UpdateAppInstance(ctx, tt.input.Region, domainAppInst) - - // 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() - - domainAppInstKey := domain.AppInstanceKey{ - Organization: tt.appInstKey.Organization, - Name: tt.appInstKey.Name, - CloudletKey: domain.CloudletKey{ - Organization: tt.appInstKey.CloudletKey.Organization, - Name: tt.appInstKey.CloudletKey.Name, - }, - } - err := client.RefreshAppInstance(ctx, tt.region, domainAppInstKey) - - 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() - - domainAppInstKey := domain.AppInstanceKey{ - Organization: tt.appInstKey.Organization, - Name: tt.appInstKey.Name, - CloudletKey: domain.CloudletKey{ - Organization: tt.appInstKey.CloudletKey.Organization, - Name: tt.appInstKey.CloudletKey.Name, - }, - } - err := client.DeleteAppInstance(ctx, tt.region, domainAppInstKey) - - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/internal/adapters/driven/edgeconnect/apps.go b/internal/adapters/driven/edgeconnect/apps.go deleted file mode 100644 index 210daf6..0000000 --- a/internal/adapters/driven/edgeconnect/apps.go +++ /dev/null @@ -1,377 +0,0 @@ -// ABOUTME: Application lifecycle management APIs for EdgeXR Master Controller -// ABOUTME: Provides typed methods for creating, querying, and deleting applications - -package edgeconnect - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/internal/http" -) - -// Note: We now use domain.DomainError for structured error handling instead of simple errors - -// CreateApp creates a new application in the specified region -// Maps to POST /auth/ctrl/CreateApp -func (c *Client) CreateApp(ctx context.Context, region string, app *domain.App) error { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/CreateApp" - - apiApp := toAPIApp(app) - input := &NewAppInput{ - Region: region, - App: *apiApp, - } - - resp, err := transport.Call(ctx, "POST", url, input) - if err != nil { - return fmt.Errorf("CreateApp failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - 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, region string, appKey domain.AppKey) (*domain.App, error) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp" - - apiAppKey := toAPIAppKey(appKey) - filter := AppFilter{ - App: App{Key: *apiAppKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("ShowApp failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - if resp.StatusCode == http.StatusNotFound { - return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, - "application not found") - } - - if resp.StatusCode >= 400 { - return nil, c.handleErrorResponse(resp, "ShowApp") - } - - // Parse streaming JSON response - var apps []App - if err := c.parseStreamingResponse(resp, &apps); err != nil { - return nil, fmt.Errorf("ShowApp failed to parse response: %w", err) - } - - if len(apps) == 0 { - return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, - "application not found") - } - - domainApp := toDomainApp(&apps[0]) - return &domainApp, nil -} - -// ShowApps retrieves all applications matching the filter criteria -// Maps to POST /auth/ctrl/ShowApp -func (c *Client) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp" - - apiAppKey := toAPIAppKey(appKey) - filter := AppFilter{ - App: App{Key: *apiAppKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("ShowApps failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { - return nil, c.handleErrorResponse(resp, "ShowApps") - } - - var apps []App - if resp.StatusCode == http.StatusNotFound { - return []domain.App{}, 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)) - - domainApps := make([]domain.App, len(apps)) - for i := range apps { - domainApps[i] = toDomainApp(&apps[i]) - } - - return domainApps, nil -} - -// UpdateApp updates the definition of an application -// Maps to POST /auth/ctrl/UpdateApp -func (c *Client) UpdateApp(ctx context.Context, region string, app *domain.App) error { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/UpdateApp" - - apiApp := toAPIApp(app) - input := &UpdateAppInput{ - Region: region, - App: *apiApp, - } - - resp, err := transport.Call(ctx, "POST", url, input) - if err != nil { - return fmt.Errorf("UpdateApp failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - 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, region string, appKey domain.AppKey) error { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp" - - apiAppKey := toAPIAppKey(appKey) - filter := AppFilter{ - App: App{Key: *apiAppKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return fmt.Errorf("DeleteApp failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - // 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 { - var responses []Response[App] - - parseErr := sdkhttp.ParseJSONLines(resp.Body, 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 - var apps []App - var messages []string - - 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 func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - bodyBytes, _ = io.ReadAll(resp.Body) - messages = append(messages, string(bodyBytes)) - } - - return &APIError{ - StatusCode: resp.StatusCode, - Messages: messages, - Body: bodyBytes, - } -} - -func toAPIApp(app *domain.App) *App { - return &App{ - Key: *toAPIAppKey(app.Key), - Deployment: app.Deployment, - ImageType: app.ImageType, - ImagePath: app.ImagePath, - AllowServerless: app.AllowServerless, - DefaultFlavor: toAPIFlavor(app.DefaultFlavor), - ServerlessConfig: app.ServerlessConfig, - DeploymentGenerator: app.DeploymentGenerator, - DeploymentManifest: app.DeploymentManifest, - RequiredOutboundConnections: toAPISecurityRules(app.RequiredOutboundConnections), - Fields: app.Fields, - } -} - -func toDomainApp(app *App) domain.App { - return domain.App{ - Key: toDomainAppKey(app.Key), - Deployment: app.Deployment, - ImageType: app.ImageType, - ImagePath: app.ImagePath, - AllowServerless: app.AllowServerless, - DefaultFlavor: toDomainFlavor(app.DefaultFlavor), - ServerlessConfig: app.ServerlessConfig, - DeploymentGenerator: app.DeploymentGenerator, - DeploymentManifest: app.DeploymentManifest, - RequiredOutboundConnections: ToDomainSecurityRules(app.RequiredOutboundConnections), - Fields: app.Fields, - } -} - -func toAPIAppKey(appKey domain.AppKey) *AppKey { - return &AppKey{ - Organization: appKey.Organization, - Name: appKey.Name, - Version: appKey.Version, - } -} - -func toDomainAppKey(appKey AppKey) domain.AppKey { - return domain.AppKey{ - Organization: appKey.Organization, - Name: appKey.Name, - Version: appKey.Version, - } -} - -func toAPIFlavor(flavor domain.Flavor) Flavor { - return Flavor{Name: flavor.Name} -} - -func toDomainFlavor(flavor Flavor) domain.Flavor { - return domain.Flavor{Name: flavor.Name} -} - -func toAPISecurityRules(rules []domain.SecurityRule) []SecurityRule { - apiRules := make([]SecurityRule, len(rules)) - for i, r := range rules { - apiRules[i] = SecurityRule{ - PortRangeMax: r.PortRangeMax, - PortRangeMin: r.PortRangeMin, - Protocol: r.Protocol, - RemoteCIDR: r.RemoteCIDR, - } - } - return apiRules -} - -func ToDomainSecurityRules(rules []SecurityRule) []domain.SecurityRule { - domainRules := make([]domain.SecurityRule, len(rules)) - for i, r := range rules { - domainRules[i] = domain.SecurityRule{ - PortRangeMax: r.PortRangeMax, - PortRangeMin: r.PortRangeMin, - Protocol: r.Protocol, - RemoteCIDR: r.RemoteCIDR, - } - } - return domainRules -} \ No newline at end of file diff --git a/internal/adapters/driven/edgeconnect/apps_test.go b/internal/adapters/driven/edgeconnect/apps_test.go deleted file mode 100644 index 69acec2..0000000 --- a/internal/adapters/driven/edgeconnect/apps_test.go +++ /dev/null @@ -1,457 +0,0 @@ -// ABOUTME: Unit tests for App management APIs using httptest mock server -// ABOUTME: Tests create, show, list, and delete operations with error conditions - -package edgeconnect - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" - "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) - if _, err := w.Write([]byte(tt.mockResponse)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - })) - 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() - domainApp := &domain.App{ - Key: domain.AppKey{ - Organization: tt.input.App.Key.Organization, - Name: tt.input.App.Key.Name, - Version: tt.input.App.Key.Version, - }, - Deployment: tt.input.App.Deployment, - ImageType: tt.input.App.ImageType, - ImagePath: tt.input.App.ImagePath, - DefaultFlavor: domain.Flavor{Name: tt.input.App.DefaultFlavor.Name}, - ServerlessConfig: tt.input.App.ServerlessConfig, - AllowServerless: tt.input.App.AllowServerless, - } - err := client.CreateApp(ctx, tt.input.Region, domainApp) - - // 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 != "" { - if _, err := w.Write([]byte(tt.mockResponse)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - } - })) - defer server.Close() - - // Create client - client := NewClient(server.URL, - WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), - ) - - // Execute test - ctx := context.Background() - domainAppKey := domain.AppKey{ - Organization: tt.appKey.Organization, - Name: tt.appKey.Name, - Version: tt.appKey.Version, - } - app, err := client.ShowApp(ctx, tt.region, domainAppKey) - - // Verify results - if tt.expectError { - assert.Error(t, err) - if tt.expectNotFound { - assert.True(t, domain.IsNotFoundError(err)) - } - } 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) - if _, err := w.Write([]byte(response)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - domainAppKey := domain.AppKey{Organization: "testorg"} - apps, err := client.ShowApps(ctx, "us-west", domainAppKey) - - 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) - if _, err := w.Write([]byte(tt.mockResponse)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - })) - 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() - domainApp := &domain.App{ - Key: domain.AppKey{ - Organization: tt.input.App.Key.Organization, - Name: tt.input.App.Key.Name, - Version: tt.input.App.Key.Version, - }, - Deployment: tt.input.App.Deployment, - ImageType: tt.input.App.ImageType, - ImagePath: tt.input.App.ImagePath, - DefaultFlavor: domain.Flavor{Name: tt.input.App.DefaultFlavor.Name}, - ServerlessConfig: tt.input.App.ServerlessConfig, - AllowServerless: tt.input.App.AllowServerless, - } - err := client.UpdateApp(ctx, tt.input.Region, domainApp) - - // 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() - - domainAppKey := domain.AppKey{ - Organization: tt.appKey.Organization, - Name: tt.appKey.Name, - Version: tt.appKey.Version, - } - err := client.DeleteApp(ctx, tt.region, domainAppKey) - - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestClientOptions(t *testing.T) { - t.Run("with auth provider", func(t *testing.T) { - authProvider := NewStaticTokenProvider("test-token") - client := NewClient("https://example.com", - WithAuthProvider(authProvider), - ) - - assert.Equal(t, authProvider, client.AuthProvider) - }) - - t.Run("with custom HTTP client", func(t *testing.T) { - httpClient := &http.Client{Timeout: 10 * time.Second} - client := NewClient("https://example.com", - WithHTTPClient(httpClient), - ) - - assert.Equal(t, httpClient, client.HTTPClient) - }) - - t.Run("with retry options", func(t *testing.T) { - retryOpts := RetryOptions{MaxRetries: 5} - client := NewClient("https://example.com", - WithRetryOptions(retryOpts), - ) - - assert.Equal(t, 5, client.RetryOpts.MaxRetries) - }) -} - -func TestAPIError(t *testing.T) { - err := &APIError{ - StatusCode: 400, - Messages: []string{"validation failed", "name is required"}, - } - - assert.Contains(t, err.Error(), "validation failed") - assert.Equal(t, 400, err.StatusCode) - assert.Len(t, err.Messages, 2) -} - - diff --git a/internal/adapters/driven/edgeconnect/auth_test.go b/internal/adapters/driven/edgeconnect/auth_test.go deleted file mode 100644 index 40b081e..0000000 --- a/internal/adapters/driven/edgeconnect/auth_test.go +++ /dev/null @@ -1,238 +0,0 @@ -// ABOUTME: Unit tests for authentication providers including username/password token flow -// ABOUTME: Tests token caching, login flow, and error conditions with mock servers - -package edgeconnect - -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") - if err := json.NewEncoder(w).Encode(response); err != nil { - t.Errorf("Failed to encode JSON response: %v", err) - } - })) - 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) - if _, err := w.Write([]byte("Invalid credentials")); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - })) - 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") - if err := json.NewEncoder(w).Encode(response); err != nil { - t.Errorf("Failed to encode JSON response: %v", err) - } - })) - 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") - if err := json.NewEncoder(w).Encode(response); err != nil { - t.Errorf("Failed to encode JSON response: %v", err) - } - })) - 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") - if err := json.NewEncoder(w).Encode(response); err != nil { - t.Errorf("Failed to encode JSON response: %v", err) - } - })) - 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") - if _, err := w.Write([]byte("invalid json response")); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - })) - defer loginServer.Close() - - provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil) - - req, _ := http.NewRequest("GET", "https://api.example.com", nil) - ctx := context.Background() - - err := provider.Attach(ctx, req) - - require.Error(t, err) - assert.Contains(t, err.Error(), "error parsing JSON") -} - -func TestNoAuthProvider(t *testing.T) { - provider := NewNoAuthProvider() - - req, _ := http.NewRequest("GET", "https://example.com", nil) - ctx := context.Background() - - err := provider.Attach(ctx, req) - - require.NoError(t, err) - assert.Empty(t, req.Header.Get("Authorization")) -} - -func TestNewClientWithCredentials(t *testing.T) { - client := NewClientWithCredentials("https://example.com", "testuser", "testpass") - - assert.Equal(t, "https://example.com", client.BaseURL) - - // Check that auth provider is UsernamePasswordProvider - authProvider, ok := client.AuthProvider.(*UsernamePasswordProvider) - require.True(t, ok, "AuthProvider should be UsernamePasswordProvider") - assert.Equal(t, "testuser", authProvider.Username) - assert.Equal(t, "testpass", authProvider.Password) - assert.Equal(t, "https://example.com", authProvider.BaseURL) -} diff --git a/internal/adapters/driven/edgeconnect/client.go b/internal/adapters/driven/edgeconnect/client.go deleted file mode 100644 index 2a79cff..0000000 --- a/internal/adapters/driven/edgeconnect/client.go +++ /dev/null @@ -1,122 +0,0 @@ -// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth -// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations - -package edgeconnect - -import ( - "net/http" - "strings" - "time" -) - -// Client represents the EdgeXR Master Controller SDK client -type Client struct { - BaseURL string - HTTPClient *http.Client - AuthProvider AuthProvider - RetryOpts RetryOptions - Logger Logger -} - -// RetryOptions configures retry behavior for API calls -type RetryOptions struct { - MaxRetries int - InitialDelay time.Duration - MaxDelay time.Duration - Multiplier float64 - RetryableHTTPStatusCodes []int -} - -// Logger interface for optional logging -type Logger interface { - Printf(format string, v ...interface{}) -} - -// DefaultRetryOptions returns sensible default retry configuration -func DefaultRetryOptions() RetryOptions { - return RetryOptions{ - MaxRetries: 3, - InitialDelay: 1 * time.Second, - MaxDelay: 30 * time.Second, - Multiplier: 2.0, - RetryableHTTPStatusCodes: []int{ - http.StatusRequestTimeout, - http.StatusTooManyRequests, - http.StatusInternalServerError, - http.StatusBadGateway, - http.StatusServiceUnavailable, - http.StatusGatewayTimeout, - }, - } -} - -// Option represents a configuration option for the client -type Option func(*Client) - -// WithHTTPClient sets a custom HTTP client -func WithHTTPClient(client *http.Client) Option { - return func(c *Client) { - c.HTTPClient = client - } -} - -// WithAuthProvider sets the authentication provider -func WithAuthProvider(auth AuthProvider) Option { - return func(c *Client) { - c.AuthProvider = auth - } -} - -// WithRetryOptions sets retry configuration -func WithRetryOptions(opts RetryOptions) Option { - return func(c *Client) { - c.RetryOpts = opts - } -} - -// WithLogger sets a logger for debugging -func WithLogger(logger Logger) Option { - return func(c *Client) { - c.Logger = logger - } -} - -// NewClient creates a new EdgeXR SDK client -func NewClient(baseURL string, options ...Option) *Client { - client := &Client{ - BaseURL: strings.TrimRight(baseURL, "/"), - HTTPClient: &http.Client{Timeout: 30 * time.Second}, - AuthProvider: NewNoAuthProvider(), - RetryOpts: DefaultRetryOptions(), - } - - for _, opt := range options { - opt(client) - } - - return client -} - -// NewClientWithCredentials creates a new EdgeXR SDK client with username/password authentication -// This matches the existing client pattern from client/client.go -func NewClientWithCredentials(baseURL, username, password string, options ...Option) *Client { - client := &Client{ - BaseURL: strings.TrimRight(baseURL, "/"), - HTTPClient: &http.Client{Timeout: 30 * time.Second}, - AuthProvider: NewUsernamePasswordProvider(baseURL, username, password, nil), - RetryOpts: DefaultRetryOptions(), - } - - for _, opt := range options { - opt(client) - } - - return client -} - -// logf logs a message if a logger is configured -func (c *Client) logf(format string, v ...interface{}) { - if c.Logger != nil { - c.Logger.Printf(format, v...) - } -} diff --git a/internal/adapters/driven/edgeconnect/cloudlet.go b/internal/adapters/driven/edgeconnect/cloudlet.go deleted file mode 100644 index c58b1a0..0000000 --- a/internal/adapters/driven/edgeconnect/cloudlet.go +++ /dev/null @@ -1,356 +0,0 @@ -// ABOUTME: Cloudlet management APIs for EdgeXR Master Controller -// ABOUTME: Provides typed methods for creating, querying, and managing edge cloudlets - -package edgeconnect - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" - sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/internal/http" -) - -// CreateCloudlet creates a new cloudlet in the specified region -// Maps to POST /auth/ctrl/CreateCloudlet -func (c *Client) CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/CreateCloudlet" - - apiCloudlet := toAPICloudlet(cloudlet) - input := &NewCloudletInput{ - Region: region, - Cloudlet: *apiCloudlet, - } - - resp, err := transport.Call(ctx, "POST", url, input) - if err != nil { - return fmt.Errorf("CreateCloudlet failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - 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, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet" - - apiCloudletKey := toAPICloudletKey(cloudletKey) - filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: apiCloudletKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("ShowCloudlet failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - if resp.StatusCode == http.StatusNotFound { - return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, - "cloudlet not found") - } - - if resp.StatusCode >= 400 { - return nil, c.handleErrorResponse(resp, "ShowCloudlet") - } - - // Parse streaming JSON response - var cloudlets []Cloudlet - if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil { - return nil, fmt.Errorf("ShowCloudlet failed to parse response: %w", err) - } - - if len(cloudlets) == 0 { - return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, - "cloudlet not found") - } - - domainCloudlet := toDomainCloudlet(&cloudlets[0]) - return &domainCloudlet, nil -} - -// ShowCloudlets retrieves all cloudlets matching the filter criteria -// Maps to POST /auth/ctrl/ShowCloudlet -func (c *Client) ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet" - - apiCloudletKey := toAPICloudletKey(cloudletKey) - filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: apiCloudletKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("ShowCloudlets failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { - return nil, c.handleErrorResponse(resp, "ShowCloudlets") - } - - var cloudlets []Cloudlet - if resp.StatusCode == http.StatusNotFound { - return []domain.Cloudlet{}, 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)) - - domainCloudlets := make([]domain.Cloudlet, len(cloudlets)) - for i := range cloudlets { - domainCloudlets[i] = toDomainCloudlet(&cloudlets[i]) - } - - return domainCloudlets, nil -} - -// DeleteCloudlet removes a cloudlet from the specified region -// Maps to POST /auth/ctrl/DeleteCloudlet -func (c *Client) DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/DeleteCloudlet" - - apiCloudletKey := toAPICloudletKey(cloudletKey) - filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: apiCloudletKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return fmt.Errorf("DeleteCloudlet failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - // 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 domain.CloudletKey, region string) (*CloudletManifest, error) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletManifest" - - apiCloudletKey := toAPICloudletKey(cloudletKey) - filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: apiCloudletKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("GetCloudletManifest failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - if resp.StatusCode == http.StatusNotFound { - return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "GetCloudletManifest", cloudletKey, region, - "cloudlet manifest not found") - } - - 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 domain.CloudletKey, region string) (*CloudletResourceUsage, error) { - transport := c.getTransport() - url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletResourceUsage" - - apiCloudletKey := toAPICloudletKey(cloudletKey) - filter := CloudletFilter{ - Cloudlet: Cloudlet{Key: apiCloudletKey}, - Region: region, - } - - resp, err := transport.Call(ctx, "POST", url, filter) - if err != nil { - return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logf("Failed to close response body: %v", err) - } - }() - - if resp.StatusCode == http.StatusNotFound { - return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "GetCloudletResourceUsage", cloudletKey, region, - "cloudlet resource usage not found") - } - - 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 -} - -func toAPICloudlet(cloudlet *domain.Cloudlet) *Cloudlet { - return &Cloudlet{ - Key: toAPICloudletKey(cloudlet.Key), - Location: toAPILocation(cloudlet.Location), - IpSupport: cloudlet.IpSupport, - NumDynamicIps: cloudlet.NumDynamicIps, - State: cloudlet.State, - Flavor: toAPIFlavor(cloudlet.Flavor), - PhysicalName: cloudlet.PhysicalName, - Region: cloudlet.Region, - NotifySrvAddr: cloudlet.NotifySrvAddr, - } -} - -func toDomainCloudlet(cloudlet *Cloudlet) domain.Cloudlet { - return domain.Cloudlet{ - Key: toDomainCloudletKey(cloudlet.Key), - Location: toDomainLocation(cloudlet.Location), - IpSupport: cloudlet.IpSupport, - NumDynamicIps: cloudlet.NumDynamicIps, - State: cloudlet.State, - Flavor: toDomainFlavor(cloudlet.Flavor), - PhysicalName: cloudlet.PhysicalName, - Region: cloudlet.Region, - NotifySrvAddr: cloudlet.NotifySrvAddr, - } -} - -func toAPILocation(location domain.Location) Location { - return Location{ - Latitude: location.Latitude, - Longitude: location.Longitude, - } -} - -func toDomainLocation(location Location) domain.Location { - return domain.Location{ - Latitude: location.Latitude, - Longitude: location.Longitude, - } -} \ No newline at end of file diff --git a/internal/adapters/driven/edgeconnect/cloudlet_test.go b/internal/adapters/driven/edgeconnect/cloudlet_test.go deleted file mode 100644 index dcf21d9..0000000 --- a/internal/adapters/driven/edgeconnect/cloudlet_test.go +++ /dev/null @@ -1,448 +0,0 @@ -// ABOUTME: Unit tests for Cloudlet management APIs using httptest mock server -// ABOUTME: Tests create, show, list, delete, manifest, and resource usage operations - -package edgeconnect - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" - "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) - if _, err := w.Write([]byte(tt.mockResponse)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - })) - 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() - domainCloudlet := &domain.Cloudlet{ - Key: domain.CloudletKey{ - Organization: tt.input.Cloudlet.Key.Organization, - Name: tt.input.Cloudlet.Key.Name, - }, - Location: domain.Location{ - Latitude: tt.input.Cloudlet.Location.Latitude, - Longitude: tt.input.Cloudlet.Location.Longitude, - }, - IpSupport: tt.input.Cloudlet.IpSupport, - NumDynamicIps: tt.input.Cloudlet.NumDynamicIps, - } - err := client.CreateCloudlet(ctx, tt.input.Region, domainCloudlet) - - // 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 != "" { - if _, err := w.Write([]byte(tt.mockResponse)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - } - })) - defer server.Close() - - // Create client - client := NewClient(server.URL, - WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), - ) - - // Execute test - ctx := context.Background() - domainCloudletKey := domain.CloudletKey{ - Organization: tt.cloudletKey.Organization, - Name: tt.cloudletKey.Name, - } - cloudlet, err := client.ShowCloudlet(ctx, tt.region, domainCloudletKey) - - // Verify results - if tt.expectError { - assert.Error(t, err) - if tt.expectNotFound { - assert.True(t, domain.IsNotFoundError(err)) - } - } 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) - if _, err := w.Write([]byte(response)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - domainCloudletKey := domain.CloudletKey{Organization: "cloudletorg"} - cloudlets, err := client.ShowCloudlets(ctx, "us-west", domainCloudletKey) - - 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() - - domainCloudletKey := domain.CloudletKey{ - Organization: tt.cloudletKey.Organization, - Name: tt.cloudletKey.Name, - } - err := client.DeleteCloudlet(ctx, tt.region, domainCloudletKey) - - 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 != "" { - if _, err := w.Write([]byte(tt.mockResponse)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - } - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - domainCloudletKey := domain.CloudletKey{ - Organization: tt.cloudletKey.Organization, - Name: tt.cloudletKey.Name, - } - manifest, err := client.GetCloudletManifest(ctx, domainCloudletKey, tt.region) - - if tt.expectError { - assert.Error(t, err) - if tt.expectNotFound { - assert.True(t, domain.IsNotFoundError(err)) - } - } 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 != "" { - if _, err := w.Write([]byte(tt.mockResponse)); err != nil { - t.Errorf("Failed to write mock response: %v", err) - } - } - })) - defer server.Close() - - client := NewClient(server.URL) - ctx := context.Background() - - domainCloudletKey := domain.CloudletKey{ - Organization: tt.cloudletKey.Organization, - Name: tt.cloudletKey.Name, - } - usage, err := client.GetCloudletResourceUsage(ctx, domainCloudletKey, tt.region) - - if tt.expectError { - assert.Error(t, err) - if tt.expectNotFound { - assert.True(t, domain.IsNotFoundError(err)) - } - } else { - require.NoError(t, err) - assert.NotNil(t, usage) - assert.Equal(t, "cloudletorg", usage.CloudletKey.Organization) - assert.Equal(t, "testcloudlet", usage.CloudletKey.Name) - assert.Equal(t, "us-west", usage.Region) - assert.Contains(t, usage.Usage, "cpu") - } - }) - } -} diff --git a/internal/adapters/driving/cli/apply.go b/internal/adapters/driving/cli/apply.go index 6a55adf..a204554 100644 --- a/internal/adapters/driving/cli/apply.go +++ b/internal/adapters/driving/cli/apply.go @@ -14,7 +14,8 @@ import ( "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/apply" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client" "github.com/spf13/cobra" ) @@ -78,27 +79,30 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { username := getEnvOrDefault("EDGEXR_USERNAME", "") password := getEnvOrDefault("EDGEXR_PASSWORD", "") - var client *edgeconnect.Client + var client *edgeconnect_client.Client if token != "" { fmt.Println("πŸ” Using Bearer token authentication") - client = edgeconnect.NewClient(baseURL, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), - edgeconnect.WithLogger(log.Default()), + client = edgeconnect_client.NewClient(baseURL, + edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)), + edgeconnect_client.WithLogger(log.Default()), ) } else if username != "" && password != "" { fmt.Println("πŸ” Using username/password authentication") - client = edgeconnect.NewClientWithCredentials(baseURL, username, password, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithLogger(log.Default()), + client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password, + edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect_client.WithLogger(log.Default()), ) } else { log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD") } - // Step 4: Create deployment planner - planner := apply.NewPlanner(client, client) + // Step 4: Create driven adapter + adapter := edgeconnect.NewAdapter(client) + + // Step 5: Create deployment planner + planner := apply.NewPlanner(adapter, adapter) // Step 5: Generate deployment plan fmt.Println("πŸ” Analyzing current state and generating deployment plan...") @@ -148,7 +152,7 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error { // Step 9: Execute deployment fmt.Println("\nπŸš€ Starting deployment...") - manager := apply.NewResourceManager(client, client, apply.WithLogger(log.Default())) + manager := apply.NewResourceManager(adapter, adapter, apply.WithLogger(log.Default())) deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent) if err != nil { return fmt.Errorf("deployment failed: %w", err) diff --git a/internal/adapters/driving/cli/root.go b/internal/adapters/driving/cli/root.go index 18e9bec..ee58d33 100644 --- a/internal/adapters/driving/cli/root.go +++ b/internal/adapters/driving/cli/root.go @@ -21,9 +21,10 @@ var ( // ServiceContainer holds injected services (simple struct - no complex container) type ServiceContainer struct { - AppService driving.AppService - InstanceService driving.AppInstanceService - CloudletService driving.CloudletService + AppService driving.AppService + InstanceService driving.AppInstanceService + CloudletService driving.CloudletService + OrganizationService driving.OrganizationService } // rootCmd represents the base command when called without any subcommands @@ -44,12 +45,13 @@ func Execute() { } // ExecuteWithServices executes CLI with dependency-injected services (simple parameter passing) -func ExecuteWithServices(appSvc driving.AppService, instanceSvc driving.AppInstanceService, cloudletSvc driving.CloudletService) { +func ExecuteWithServices(appSvc driving.AppService, instanceSvc driving.AppInstanceService, cloudletSvc driving.CloudletService, orgSvc driving.OrganizationService) { // Simple dependency injection - just store services in container services = &ServiceContainer{ - AppService: appSvc, - InstanceService: instanceSvc, - CloudletService: cloudletSvc, + AppService: appSvc, + InstanceService: instanceSvc, + CloudletService: cloudletSvc, + OrganizationService: orgSvc, } Execute() @@ -58,6 +60,8 @@ func ExecuteWithServices(appSvc driving.AppService, instanceSvc driving.AppInsta func init() { cobra.OnInitialize(initConfig) + rootCmd.AddCommand(organizationCmd) + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.edge-connect.yaml)") rootCmd.PersistentFlags().StringVar(&baseURL, "base-url", "", "base URL for the Edge Connect API") rootCmd.PersistentFlags().StringVar(&username, "username", "", "username for authentication") diff --git a/internal/adapters/internal/http/transport.go b/internal/adapters/internal/http/transport.go deleted file mode 100644 index 75828d6..0000000 --- a/internal/adapters/internal/http/transport.go +++ /dev/null @@ -1,225 +0,0 @@ -// ABOUTME: HTTP transport layer with retry logic and request/response handling -// ABOUTME: Provides resilient HTTP communication with context support and error wrapping - -package http - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "math" - "math/rand" - "net/http" - "os" - "time" - - "github.com/hashicorp/go-retryablehttp" -) - -// Transport wraps HTTP operations with retry logic and error handling -type Transport struct { - client *retryablehttp.Client - authProvider AuthProvider - logger Logger -} - -// AuthProvider interface for attaching authentication -type AuthProvider interface { - Attach(ctx context.Context, req *http.Request) error -} - -// Logger interface for request/response logging -type Logger interface { - Printf(format string, v ...interface{}) -} - -// RetryOptions configures retry behavior -type RetryOptions struct { - MaxRetries int - InitialDelay time.Duration - MaxDelay time.Duration - Multiplier float64 - RetryableHTTPStatusCodes []int -} - -// NewTransport creates a new HTTP transport with retry capabilities -func NewTransport(opts RetryOptions, auth AuthProvider, logger Logger) *Transport { - client := retryablehttp.NewClient() - - // Configure retry policy - client.RetryMax = opts.MaxRetries - client.RetryWaitMin = opts.InitialDelay - client.RetryWaitMax = opts.MaxDelay - - // Custom retry policy that considers both network errors and HTTP status codes - client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) { - // Default retry for network errors - if err != nil { - return true, nil - } - - // Check if status code is retryable - if resp != nil { - for _, code := range opts.RetryableHTTPStatusCodes { - if resp.StatusCode == code { - return true, nil - } - } - } - - return false, nil - } - - // Custom backoff with jitter - client.Backoff = func(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration { - mult := math.Pow(opts.Multiplier, float64(attemptNum)) - sleep := time.Duration(mult) * min - if sleep > max { - sleep = max - } - // Add jitter - jitter := time.Duration(rand.Float64() * float64(sleep) * 0.1) - return sleep + jitter - } - - // Disable default logging if no logger provided - if logger == nil { - client.Logger = nil - } - - return &Transport{ - client: client, - authProvider: auth, - logger: logger, - } -} - -// Call executes an HTTP request with retry logic and returns typed response -func (t *Transport) Call(ctx context.Context, method, url string, body interface{}) (*http.Response, error) { - var reqBody io.Reader - - // Marshal request body if provided - if body != nil { - jsonData, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) - } - reqBody = bytes.NewReader(jsonData) - } - - // Create retryable request - req, err := retryablehttp.NewRequestWithContext(ctx, method, url, reqBody) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Set headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - // Add authentication - if t.authProvider != nil { - if err := t.authProvider.Attach(ctx, req.Request); err != nil { - return nil, fmt.Errorf("failed to attach auth: %w", err) - } - } - - // Log request - if t.logger != nil { - t.logger.Printf("HTTP %s %s", method, url) - t.logger.Printf("BODY %s", reqBody) - } - - // Execute request - resp, err := t.client.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - - // Log response - if t.logger != nil { - t.logger.Printf("HTTP %s %s -> %d", method, url, resp.StatusCode) - } - - return resp, nil -} - -// CallJSON executes a request and unmarshals the response into a typed result -func (t *Transport) CallJSON(ctx context.Context, method, url string, body interface{}, result interface{}) (*http.Response, error) { - resp, err := t.Call(ctx, method, url, body) - if err != nil { - return resp, err - } - defer func() { - if err := resp.Body.Close(); err != nil { - // Log error but don't fail the operation - fmt.Fprintf(os.Stderr, "Warning: failed to close response body: %v\n", err) - } - }() - - // Read response body - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return resp, fmt.Errorf("failed to read response body: %w", err) - } - - // For error responses, don't try to unmarshal into result type - if resp.StatusCode >= 400 { - return resp, &HTTPError{ - StatusCode: resp.StatusCode, - Status: resp.Status, - Body: respBody, - } - } - - // Unmarshal successful response - if result != nil && len(respBody) > 0 { - if err := json.Unmarshal(respBody, result); err != nil { - return resp, fmt.Errorf("failed to unmarshal response: %w", err) - } - } - - return resp, nil -} - -// HTTPError represents an HTTP error response -type HTTPError struct { - StatusCode int `json:"status_code"` - Status string `json:"status"` - Body []byte `json:"-"` -} - -func (e *HTTPError) Error() string { - if len(e.Body) > 0 { - return fmt.Sprintf("HTTP %d %s: %s", e.StatusCode, e.Status, string(e.Body)) - } - return fmt.Sprintf("HTTP %d %s", e.StatusCode, e.Status) -} - -// IsRetryable returns true if the error indicates a retryable condition -func (e *HTTPError) IsRetryable() bool { - return e.StatusCode >= 500 || e.StatusCode == 429 || e.StatusCode == 408 -} - -// ParseJSONLines parses streaming JSON response line by line -func ParseJSONLines(body io.Reader, callback func([]byte) error) error { - decoder := json.NewDecoder(body) - - for { - var raw json.RawMessage - if err := decoder.Decode(&raw); err != nil { - if err == io.EOF { - break - } - return fmt.Errorf("failed to decode JSON line: %w", err) - } - - if err := callback(raw); err != nil { - return err - } - } - - return nil -} diff --git a/internal/application/apply/manager.go b/internal/application/apply/manager.go index 100c622..5603843 100644 --- a/internal/application/apply/manager.go +++ b/internal/application/apply/manager.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" ) diff --git a/internal/application/apply/manager_test.go b/internal/application/apply/manager_test.go index 4d0d7a5..85f6919 100644 --- a/internal/application/apply/manager_test.go +++ b/internal/application/apply/manager_test.go @@ -10,9 +10,8 @@ import ( "testing" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -248,7 +247,7 @@ func TestApplyDeploymentAppFailure(t *testing.T) { // Mock app creation failure - deployment should stop here mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) + Return(fmt.Errorf("Server error")) ctx := context.Background() result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content") @@ -277,7 +276,7 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")). Return(nil) mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}}) + Return(fmt.Errorf("Instance creation failed")) // Mock rollback operations mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")). @@ -495,7 +494,7 @@ func TestRollbackDeploymentFailure(t *testing.T) { // Mock rollback failure mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")). - Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Delete failed"}}) + Return(fmt.Errorf("Delete failed")) ctx := context.Background() err := manager.RollbackDeployment(ctx, result) diff --git a/internal/application/apply/mocks_test.go b/internal/application/apply/mocks_test.go index 5d4de0e..638b656 100644 --- a/internal/application/apply/mocks_test.go +++ b/internal/application/apply/mocks_test.go @@ -3,7 +3,7 @@ package apply import ( "context" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" "github.com/stretchr/testify/mock" diff --git a/internal/application/apply/planner.go b/internal/application/apply/planner.go index be345a0..973c502 100644 --- a/internal/application/apply/planner.go +++ b/internal/application/apply/planner.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" ) diff --git a/internal/application/apply/planner_test.go b/internal/application/apply/planner_test.go index 1df2ab9..235986c 100644 --- a/internal/application/apply/planner_test.go +++ b/internal/application/apply/planner_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" diff --git a/internal/application/apply/strategy.go b/internal/application/apply/strategy.go index 11f28ef..ba5470b 100644 --- a/internal/application/apply/strategy.go +++ b/internal/application/apply/strategy.go @@ -7,7 +7,7 @@ import ( "fmt" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" ) diff --git a/internal/application/apply/strategy_recreate.go b/internal/application/apply/strategy_recreate.go index 1b92948..4d52567 100644 --- a/internal/application/apply/strategy_recreate.go +++ b/internal/application/apply/strategy_recreate.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" ) diff --git a/internal/application/apply/types.go b/internal/application/apply/types.go index 82de329..de96dd0 100644 --- a/internal/application/apply/types.go +++ b/internal/application/apply/types.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" ) diff --git a/internal/config/config_test.go b/internal/infrastructure/config/config_test.go similarity index 100% rename from internal/config/config_test.go rename to internal/infrastructure/config/config_test.go diff --git a/internal/config/example_test.go b/internal/infrastructure/config/example_test.go similarity index 59% rename from internal/config/example_test.go rename to internal/infrastructure/config/example_test.go index dfa3840..a6f3a09 100644 --- a/internal/config/example_test.go +++ b/internal/infrastructure/config/example_test.go @@ -3,7 +3,6 @@ package config import ( - "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -11,56 +10,47 @@ import ( ) func TestParseExampleConfig(t *testing.T) { + // The base path is relative to the location of this test file parser := NewParser() - - // Parse the actual example file (now that we've created the manifest file) - examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig.yaml") - config, parsedManifest, err := parser.ParseFile(examplePath) - - // This should now succeed with full validation + cfg, _, err := parser.ParseFile("../../../sdk/examples/comprehensive/EdgeConnectConfig.yaml") require.NoError(t, err) - require.NotNil(t, config) - require.NotEmpty(t, parsedManifest) + require.NotNil(t, cfg) - // Validate the parsed structure - assert.Equal(t, "edgeconnect-deployment", config.Kind) - assert.Equal(t, "edge-app-demo", config.Metadata.Name) - - // Check k8s app configuration - require.NotNil(t, config.Spec.K8sApp) - assert.Equal(t, "1.0.0", config.Metadata.AppVersion) - // Note: ManifestFile path should be resolved to absolute path - assert.Contains(t, config.Spec.K8sApp.ManifestFile, "k8s-deployment.yaml") + // Basic validation + assert.Equal(t, "edgeconnect-deployment", cfg.Kind) + assert.Equal(t, "edge-app-demo", cfg.Metadata.Name) + assert.NotNil(t, cfg.Spec.K8sApp) + assert.NotEmpty(t, cfg.Spec.K8sApp.ManifestFile) // Check infrastructure template - require.Len(t, config.Spec.InfraTemplate, 1) - infra := config.Spec.InfraTemplate[0] + require.Len(t, cfg.Spec.InfraTemplate, 1) + infra := cfg.Spec.InfraTemplate[0] assert.Equal(t, "EU", infra.Region) assert.Equal(t, "TelekomOP", infra.CloudletOrg) assert.Equal(t, "Munich", infra.CloudletName) assert.Equal(t, "EU.small", infra.FlavorName) // Check network configuration - require.NotNil(t, config.Spec.Network) - require.Len(t, config.Spec.Network.OutboundConnections, 2) + require.NotNil(t, cfg.Spec.Network) + require.Len(t, cfg.Spec.Network.OutboundConnections, 2) - conn1 := config.Spec.Network.OutboundConnections[0] + conn1 := cfg.Spec.Network.OutboundConnections[0] assert.Equal(t, "tcp", conn1.Protocol) assert.Equal(t, 80, conn1.PortRangeMin) assert.Equal(t, 80, conn1.PortRangeMax) assert.Equal(t, "0.0.0.0/0", conn1.RemoteCIDR) - conn2 := config.Spec.Network.OutboundConnections[1] + conn2 := cfg.Spec.Network.OutboundConnections[1] assert.Equal(t, "tcp", conn2.Protocol) assert.Equal(t, 443, conn2.PortRangeMin) assert.Equal(t, 443, conn2.PortRangeMax) assert.Equal(t, "0.0.0.0/0", conn2.RemoteCIDR) // Test utility methods - assert.Equal(t, "edge-app-demo", config.Metadata.Name) - assert.Contains(t, config.Spec.GetManifestFile(), "k8s-deployment.yaml") - assert.True(t, config.Spec.IsK8sApp()) - assert.False(t, config.Spec.IsDockerApp()) + assert.Equal(t, "edge-app-demo", cfg.Metadata.Name) + assert.Contains(t, cfg.Spec.GetManifestFile(), "k8s-deployment.yaml") + assert.True(t, cfg.Spec.IsK8sApp()) + assert.False(t, cfg.Spec.IsDockerApp()) } func TestValidateExampleStructure(t *testing.T) { @@ -70,13 +60,13 @@ func TestValidateExampleStructure(t *testing.T) { config := &EdgeConnectConfig{ Kind: "edgeconnect-deployment", Metadata: Metadata{ - Name: "edge-app-demo", - AppVersion: "1.0.0", + Name: "edge-app-demo", + AppVersion: "1.0.0", Organization: "edp2", }, Spec: Spec{ DockerApp: &DockerApp{ // Use DockerApp to avoid manifest file validation - Image: "nginx:latest", + Image: "nginx:latest", }, InfraTemplate: []InfraTemplate{ { diff --git a/internal/config/parser.go b/internal/infrastructure/config/parser.go similarity index 100% rename from internal/config/parser.go rename to internal/infrastructure/config/parser.go diff --git a/internal/config/parser_test.go b/internal/infrastructure/config/parser_test.go similarity index 100% rename from internal/config/parser_test.go rename to internal/infrastructure/config/parser_test.go diff --git a/internal/config/types.go b/internal/infrastructure/config/types.go similarity index 100% rename from internal/config/types.go rename to internal/infrastructure/config/types.go diff --git a/internal/adapters/driven/edgeconnect/auth.go b/internal/infrastructure/edgeconnect_client/auth.go similarity index 99% rename from internal/adapters/driven/edgeconnect/auth.go rename to internal/infrastructure/edgeconnect_client/auth.go index a5e0ea0..b321ec7 100644 --- a/internal/adapters/driven/edgeconnect/auth.go +++ b/internal/infrastructure/edgeconnect_client/auth.go @@ -1,7 +1,7 @@ // ABOUTME: Authentication providers for EdgeXR Master Controller API // ABOUTME: Supports Bearer token authentication with pluggable provider interface -package edgeconnect +package edgeconnect_client import ( "bytes" diff --git a/internal/infrastructure/edgeconnect_client/client.go b/internal/infrastructure/edgeconnect_client/client.go new file mode 100644 index 0000000..0face70 --- /dev/null +++ b/internal/infrastructure/edgeconnect_client/client.go @@ -0,0 +1,204 @@ +// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth +// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations + +package edgeconnect_client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/transport" +) + +// Client represents the EdgeXR Master Controller SDK client +type Client struct { + BaseURL string + HTTPClient *http.Client + AuthProvider AuthProvider + RetryOpts transport.RetryOptions + Logger Logger +} + +// Logger interface for optional logging +type Logger interface { + Printf(format string, v ...interface{}) +} + +// DefaultRetryOptions returns sensible default retry configuration +func DefaultRetryOptions() transport.RetryOptions { + return transport.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 transport.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 +func NewClientWithCredentials(baseURL, username, password string, options ...Option) *Client { + // Pass the HTTP client from options to the provider if it exists + tempClient := &Client{ + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + } + for _, opt := range options { + opt(tempClient) + } + + client := &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTPClient: tempClient.HTTPClient, + AuthProvider: NewUsernamePasswordProvider(baseURL, username, password, tempClient.HTTPClient), + RetryOpts: DefaultRetryOptions(), + } + + // Apply other options again, which might override the HTTPClient, but that's fine. + 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...) + } +} + +// Call performs a generic API call +func (c *Client) Call(ctx context.Context, method, path string, body, result interface{}) (*http.Response, error) { + t := c.getTransport() + url := c.BaseURL + path + + resp, err := t.Call(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf("API call to %s %s failed: %w", method, path, err) + } + + // If result is nil, the caller doesn't expect a body to be parsed. + // They are responsible for closing the response body. + if result == nil { + return resp, nil + } + + // If result is not nil, we handle the body and closing it. + defer func() { + if err := resp.Body.Close(); err != nil { + c.Logf("Failed to close response body: %v", err) + } + }() + + if resp.StatusCode >= 400 { + return resp, c.handleErrorResponse(resp, fmt.Sprintf("%s %s", method, path)) + } + + // Handle different response types + switch v := result.(type) { + case *string: + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return resp, fmt.Errorf("failed to read response body: %w", err) + } + *v = string(bodyBytes) + case io.Writer: + _, err := io.Copy(v, resp.Body) + if err != nil { + return resp, fmt.Errorf("failed to write response body: %w", err) + } + default: + // Default to JSON decoding + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return resp, fmt.Errorf("failed to decode JSON response: %w", err) + } + } + + return resp, nil +} + +// getTransport creates an HTTP transport with current client settings +func (c *Client) getTransport() *transport.Transport { + return transport.NewTransport( + c.RetryOpts, + 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, _ := io.ReadAll(resp.Body) // Read body, ignore error as it might be empty + if len(bodyBytes) > 0 { + messages = append(messages, string(bodyBytes)) + } + + return &APIError{ + StatusCode: resp.StatusCode, + Messages: messages, + Body: bodyBytes, + } +} diff --git a/internal/adapters/driven/edgeconnect/types.go b/internal/infrastructure/edgeconnect_client/types.go similarity index 99% rename from internal/adapters/driven/edgeconnect/types.go rename to internal/infrastructure/edgeconnect_client/types.go index 5fd5245..a35844c 100644 --- a/internal/adapters/driven/edgeconnect/types.go +++ b/internal/infrastructure/edgeconnect_client/types.go @@ -1,7 +1,7 @@ // ABOUTME: Core type definitions for EdgeXR Master Controller SDK // ABOUTME: These types are based on the swagger API specification and existing client patterns -package edgeconnect +package edgeconnect_client import ( "encoding/json" diff --git a/internal/infrastructure/transport/parser.go b/internal/infrastructure/transport/parser.go new file mode 100644 index 0000000..d873e93 --- /dev/null +++ b/internal/infrastructure/transport/parser.go @@ -0,0 +1,23 @@ +package transport + +import ( + "encoding/json" + "fmt" + "io" +) + +// ParseJSONLines reads a response body line by line and calls a handler for each JSON object. +// This is useful for streaming JSON responses. +func ParseJSONLines(body io.Reader, handler func(line []byte) error) error { + decoder := json.NewDecoder(body) + for decoder.More() { + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + return fmt.Errorf("failed to decode JSON stream: %w", err) + } + if err := handler(raw); err != nil { + return fmt.Errorf("error processing JSON line: %w", err) + } + } + return nil +} diff --git a/internal/infrastructure/transport/transport.go b/internal/infrastructure/transport/transport.go new file mode 100644 index 0000000..e60ab8b --- /dev/null +++ b/internal/infrastructure/transport/transport.go @@ -0,0 +1,137 @@ +package transport + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "time" +) + +// AuthProvider defines the interface for attaching authentication to requests. +type AuthProvider interface { + Attach(ctx context.Context, req *http.Request) error +} + +// Logger defines the interface for logging. +type Logger interface { + Printf(format string, v ...interface{}) +} + +// RetryOptions configures the retry behavior for API calls. +type RetryOptions struct { + MaxRetries int + InitialDelay time.Duration + MaxDelay time.Duration + Multiplier float64 + RetryableHTTPStatusCodes []int +} + +// Transport handles the lifecycle of an HTTP request, including authentication and retries. +type Transport struct { + retryOptions RetryOptions + authProvider AuthProvider + logger Logger + httpClient *http.Client +} + +// NewTransport creates a new Transport. +func NewTransport(retryOptions RetryOptions, authProvider AuthProvider, logger Logger) *Transport { + return &Transport{ + retryOptions: retryOptions, + authProvider: authProvider, + logger: logger, + httpClient: &http.Client{}, // Use a default client + } +} + +// Call executes an HTTP request with the configured transport options. +func (t *Transport) Call(ctx context.Context, method, url string, body interface{}) (*http.Response, error) { + var reqBody io.Reader + if body != nil { + // Marshal body to JSON + jsonData, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewBuffer(jsonData) + } + + var resp *http.Response + var err error + + for i := 0; i <= t.retryOptions.MaxRetries; i++ { + // Create a new request for each attempt + req, reqErr := http.NewRequestWithContext(ctx, method, url, reqBody) + if reqErr != nil { + return nil, fmt.Errorf("failed to create request: %w", reqErr) + } + req.Header.Set("Content-Type", "application/json") + + // Attach authentication + if authErr := t.authProvider.Attach(ctx, req); authErr != nil { + return nil, fmt.Errorf("failed to attach authentication: %w", authErr) + } + + // Perform the request + resp, err = t.httpClient.Do(req) + if err != nil { + t.logf("Request failed (attempt %d): %v", i+1, err) + // Decide if we should retry based on the error (e.g., network errors) + if i < t.retryOptions.MaxRetries { + time.Sleep(t.calculateBackoff(i)) + continue + } + return nil, fmt.Errorf("request failed after %d attempts: %w", t.retryOptions.MaxRetries+1, err) + } + + // Check if we should retry based on status code + if t.isRetryable(resp.StatusCode) && i < t.retryOptions.MaxRetries { + t.logf("Request returned retryable status %d (attempt %d)", resp.StatusCode, i+1) + // We need to close the body before retrying + if resp.Body != nil { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + time.Sleep(t.calculateBackoff(i)) + continue + } + + // If not retryable, break the loop + break + } + + return resp, err +} + +// isRetryable checks if an HTTP status code is in the list of retryable codes. +func (t *Transport) isRetryable(statusCode int) bool { + for _, code := range t.retryOptions.RetryableHTTPStatusCodes { + if statusCode == code { + return true + } + } + return false +} + +// calculateBackoff computes the delay for the next retry attempt. +func (t *Transport) calculateBackoff(attempt int) time.Duration { + if attempt == 0 { + return t.retryOptions.InitialDelay + } + delay := float64(t.retryOptions.InitialDelay) * math.Pow(t.retryOptions.Multiplier, float64(attempt)) + if delay > float64(t.retryOptions.MaxDelay) { + return t.retryOptions.MaxDelay + } + return time.Duration(delay) +} + +// logf logs a message if a logger is configured. +func (t *Transport) logf(format string, v ...interface{}) { + if t.logger != nil { + t.logger.Printf(format, v...) + } +} diff --git a/sdk/examples/comprehensive/main.go b/sdk/examples/comprehensive/main.go index 8dfe1c9..f5d736c 100644 --- a/sdk/examples/comprehensive/main.go +++ b/sdk/examples/comprehensive/main.go @@ -9,11 +9,11 @@ import ( "log" "net/http" "os" - "strings" "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client" ) func main() { @@ -25,25 +25,27 @@ func main() { username := getEnvOrDefault("EDGEXR_USERNAME", "") password := getEnvOrDefault("EDGEXR_PASSWORD", "") - var client *edgeconnect.Client + var client *edgeconnect_client.Client if token != "" { fmt.Println("πŸ” Using Bearer token authentication") - client = edgeconnect.NewClient(baseURL, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), - edgeconnect.WithLogger(log.Default()), + client = edgeconnect_client.NewClient(baseURL, + edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)), + edgeconnect_client.WithLogger(log.Default()), ) } else if username != "" && password != "" { fmt.Println("πŸ” Using username/password authentication") - client = edgeconnect.NewClientWithCredentials(baseURL, username, password, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithLogger(log.Default()), + client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password, + edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect_client.WithLogger(log.Default()), ) } else { log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD") } + adapter := edgeconnect.NewAdapter(client) + ctx := context.Background() // Configuration for the workflow @@ -62,7 +64,7 @@ func main() { fmt.Printf("Organization: %s, Region: %s\n\n", config.Organization, config.Region) // Run the complete workflow - if err := runComprehensiveWorkflow(ctx, client, config); err != nil { + if err := runComprehensiveWorkflow(ctx, adapter, config); err != nil { log.Fatalf("Workflow failed: %v", err) } @@ -86,7 +88,7 @@ type WorkflowConfig struct { FlavorName string } -func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config WorkflowConfig) error { +func runComprehensiveWorkflow(ctx context.Context, adapter *edgeconnect.Adapter, config WorkflowConfig) error { var domainAppKey domain.AppKey var domainInstanceKey domain.AppInstanceKey var domainCloudletKey domain.CloudletKey @@ -95,10 +97,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 1. Create Application fmt.Println("\n1️⃣ Creating application...") - app := &edgeconnect.NewAppInput{ + app := &NewAppInput{ Region: config.Region, - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ + App: App{ + Key: AppKey{ Organization: config.Organization, Name: config.AppName, Version: config.AppVersion, @@ -106,10 +108,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Deployment: "kubernetes", ImageType: "ImageTypeDocker", // field is ignored ImagePath: "https://registry-1.docker.io/library/nginx:latest", // must be set. Even for kubernetes - DefaultFlavor: edgeconnect.Flavor{Name: config.FlavorName}, + DefaultFlavor: Flavor{Name: config.FlavorName}, ServerlessConfig: struct{}{}, // must be set AllowServerless: true, // must be set to true for kubernetes - RequiredOutboundConnections: []edgeconnect.SecurityRule{ + RequiredOutboundConnections: []SecurityRule{ { Protocol: "tcp", PortRangeMin: 80, @@ -138,17 +140,17 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config DefaultFlavor: domain.Flavor{Name: app.App.DefaultFlavor.Name}, ServerlessConfig: app.App.ServerlessConfig, AllowServerless: app.App.AllowServerless, - RequiredOutboundConnections: edgeconnect.ToDomainSecurityRules(app.App.RequiredOutboundConnections), + RequiredOutboundConnections: toDomainSecurityRules(app.App.RequiredOutboundConnections), } - if err := c.CreateApp(ctx, app.Region, domainApp); err != nil { + if err := adapter.CreateApp(ctx, app.Region, domainApp); err != nil { return fmt.Errorf("failed to create app: %w", err) } fmt.Printf("βœ… App created: %s/%s v%s\n", config.Organization, config.AppName, config.AppVersion) // 2. Show Application Details fmt.Println("\n2️⃣ Querying application details...") - appKey := edgeconnect.AppKey{ + appKey := AppKey{ Organization: config.Organization, Name: config.AppName, Version: config.AppVersion, @@ -159,7 +161,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Name: appKey.Name, Version: appKey.Version, } - appDetails, err := c.ShowApp(ctx, config.Region, domainAppKey) + appDetails, err := adapter.ShowApp(ctx, config.Region, domainAppKey) if err != nil { return fmt.Errorf("failed to show app: %w", err) } @@ -172,7 +174,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 3. List Applications in Organization fmt.Println("\n3️⃣ Listing applications in organization...") filter := domain.AppKey{Organization: config.Organization} - apps, err := c.ShowApps(ctx, config.Region, filter) + apps, err := adapter.ShowApps(ctx, config.Region, filter) if err != nil { return fmt.Errorf("failed to list apps: %w", err) } @@ -185,19 +187,19 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 4. Create Application Instance fmt.Println("\n4️⃣ Creating application instance...") - instance := &edgeconnect.NewAppInstanceInput{ + instance := &NewAppInstanceInput{ Region: config.Region, - AppInst: edgeconnect.AppInstance{ - Key: edgeconnect.AppInstanceKey{ + AppInst: AppInstance{ + Key: AppInstanceKey{ Organization: config.Organization, Name: config.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: CloudletKey{ Organization: config.CloudletOrg, Name: config.CloudletName, }, }, AppKey: appKey, - Flavor: edgeconnect.Flavor{Name: config.FlavorName}, + Flavor: Flavor{Name: config.FlavorName}, }, } @@ -217,7 +219,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config }, Flavor: domain.Flavor{Name: instance.AppInst.Flavor.Name}, } - if err := c.CreateAppInstance(ctx, instance.Region, domainAppInst); err != nil { + if err := adapter.CreateAppInstance(ctx, instance.Region, domainAppInst); err != nil { return fmt.Errorf("failed to create app instance: %w", err) } fmt.Printf("βœ… App instance created: %s on cloudlet %s/%s\n", @@ -225,16 +227,16 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 5. Wait for Application Instance to be Ready fmt.Println("\n5️⃣ Waiting for application instance to be ready...") - instanceKey := edgeconnect.AppInstanceKey{ + instanceKey := AppInstanceKey{ Organization: config.Organization, Name: config.InstanceName, - CloudletKey: edgeconnect.CloudletKey{ + CloudletKey: CloudletKey{ Organization: config.CloudletOrg, Name: config.CloudletName, }, } - instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, config.Region, 5*time.Minute) + instanceDetails, err := waitForInstanceReady(ctx, adapter, instanceKey, config.Region, 5*time.Minute) if err != nil { return fmt.Errorf("failed to wait for instance ready: %w", err) } @@ -249,7 +251,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 6. List Application Instances fmt.Println("\n6️⃣ Listing application instances...") domainAppInstKey = domain.AppInstanceKey{Organization: config.Organization} - instances, err := c.ShowAppInstances(ctx, config.Region, domainAppInstKey) + instances, err := adapter.ShowAppInstances(ctx, config.Region, domainAppInstKey) if err != nil { return fmt.Errorf("failed to list app instances: %w", err) } @@ -269,7 +271,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Name: instanceKey.CloudletKey.Name, }, } - if err := c.RefreshAppInstance(ctx, config.Region, domainInstanceKey); err != nil { + if err := adapter.RefreshAppInstance(ctx, config.Region, domainInstanceKey); err != nil { return fmt.Errorf("failed to refresh app instance: %w", err) } fmt.Printf("βœ… Instance refreshed: %s\n", config.InstanceName) @@ -278,7 +280,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config // 8. Show Cloudlet Details fmt.Println("\n8️⃣ Querying cloudlet information...") - cloudletKey := edgeconnect.CloudletKey{ + cloudletKey := CloudletKey{ Organization: config.CloudletOrg, Name: config.CloudletName, } @@ -287,7 +289,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Organization: cloudletKey.Organization, Name: cloudletKey.Name, } - cloudlets, err := c.ShowCloudlets(ctx, config.Region, domainCloudletKey) + cloudlets, err := adapter.ShowCloudlets(ctx, config.Region, domainCloudletKey) if err != nil { // This might fail in demo environment, so we'll continue fmt.Printf("⚠️ Could not retrieve cloudlet details: %v\n", err) @@ -307,7 +309,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Organization: cloudletKey.Organization, Name: cloudletKey.Name, } - manifest, err := c.GetCloudletManifest(ctx, domainCloudletKey, config.Region) + manifest, err := adapter.GetCloudletManifest(ctx, domainCloudletKey, config.Region) if err != nil { fmt.Printf("⚠️ Could not retrieve cloudlet manifest: %v\n", err) } else { @@ -320,7 +322,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Organization: cloudletKey.Organization, Name: cloudletKey.Name, } - usage, err := c.GetCloudletResourceUsage(ctx, domainCloudletKey, config.Region) + usage, err := adapter.GetCloudletResourceUsage(ctx, domainCloudletKey, config.Region) if err != nil { fmt.Printf("⚠️ Could not retrieve cloudlet usage: %v\n", err) } else { @@ -342,7 +344,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Name: instanceKey.CloudletKey.Name, }, } - if err := c.DeleteAppInstance(ctx, config.Region, domainInstanceKey); err != nil { + if err := adapter.DeleteAppInstance(ctx, config.Region, domainInstanceKey); err != nil { return fmt.Errorf("failed to delete app instance: %w", err) } fmt.Printf("βœ… App instance deleted: %s\n", config.InstanceName) @@ -354,7 +356,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Name: appKey.Name, Version: appKey.Version, } - if err := c.DeleteApp(ctx, config.Region, domainAppKey); err != nil { + if err := adapter.DeleteApp(ctx, config.Region, domainAppKey); err != nil { return fmt.Errorf("failed to delete app: %w", err) } fmt.Printf("βœ… App deleted: %s/%s v%s\n", config.Organization, config.AppName, config.AppVersion) @@ -366,7 +368,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Name: appKey.Name, Version: appKey.Version, } - _, err = c.ShowApp(ctx, config.Region, domainAppKey) + _, err = adapter.ShowApp(ctx, config.Region, domainAppKey) if err != nil && domain.IsNotFoundError(err) { fmt.Printf("βœ… Cleanup verified - app no longer exists\n") } else if err != nil { @@ -386,7 +388,7 @@ func getEnvOrDefault(key, defaultValue string) string { } // waitForInstanceReady polls the instance status until it's no longer "Creating" or timeout -func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKey edgeconnect.AppInstanceKey, region string, timeout time.Duration) (edgeconnect.AppInstance, error) { +func waitForInstanceReady(ctx context.Context, adapter *edgeconnect.Adapter, instanceKey AppInstanceKey, region string, timeout time.Duration) (AppInstance, error) { timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -398,8 +400,7 @@ func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKe for { select { case <-timeoutCtx.Done(): - return edgeconnect.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout) - + return AppInstance{}, fmt.Errorf("timed out waiting for instance to be ready") case <-ticker.C: domainInstanceKey := domain.AppInstanceKey{ Organization: instanceKey.Organization, @@ -409,31 +410,118 @@ func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKe Name: instanceKey.CloudletKey.Name, }, } - instance, err := c.ShowAppInstance(timeoutCtx, region, domainInstanceKey) + instance, err := adapter.ShowAppInstance(ctx, region, domainInstanceKey) if err != nil { - // Log error but continue polling - fmt.Printf(" ⚠️ Error checking instance state: %v\n", err) + // Continue polling on transient errors + fmt.Printf(" (polling) transient error: %v\n", err) continue } - fmt.Printf(" πŸ“Š Instance state: %s", instance.State) - if instance.PowerState != "" { - fmt.Printf(" (power: %s)", instance.PowerState) - } - fmt.Printf("\n") - - // Check if instance is ready (not in creating state) - state := strings.ToLower(instance.State) - if state != "" && state != "creating" && state != "create requested" { - if state == "ready" || state == "running" { - return *edgeconnect.ToAPIAppInstance(instance), nil } else if state == "error" || state == "failed" || strings.Contains(state, "error") { - return *edgeconnect.ToAPIAppInstance(instance), fmt.Errorf("instance entered error state: %s", instance.State) - } else { - // Instance is in some other stable state (not creating) - fmt.Printf(" βœ… Instance reached stable state: %s\n", instance.State) - return *edgeconnect.ToAPIAppInstance(instance), nil + // Check for a terminal state + if instance.State != "Creating" && instance.State != "Updating" { + if instance.State == "Ready" { + return fromDomainAppInstance(instance), nil } + return AppInstance{}, fmt.Errorf("instance entered a non-ready terminal state: %s", instance.State) } + fmt.Printf(" (polling) current state: %s...\n", instance.State) } } } + +// The structs below are included to make the example self-contained and runnable. +// In a real application, these would be defined in the `edgeconnect` package. + +type NewAppInput struct { + Region string + App App + RequiredOutboundConnections []SecurityRule +} + +type App struct { + Key AppKey + Deployment string + ImageType string + ImagePath string + DefaultFlavor Flavor + ServerlessConfig interface{} + AllowServerless bool + RequiredOutboundConnections []SecurityRule +} + +type AppKey struct { + Organization string + Name string + Version string +} + +type Flavor struct { + Name string +} + +type SecurityRule struct { + Protocol string + PortRangeMin int + PortRangeMax int + RemoteCIDR string +} + +type NewAppInstanceInput struct { + Region string + AppInst AppInstance +} + +type AppInstance struct { + Key AppInstanceKey + AppKey AppKey + Flavor Flavor + State string + PowerState string +} + +type AppInstanceKey struct { + Organization string + Name string + CloudletKey CloudletKey +} + +type CloudletKey struct { + Organization string + Name string +} + +func toDomainSecurityRules(rules []SecurityRule) []domain.SecurityRule { + domainRules := make([]domain.SecurityRule, len(rules)) + for i, r := range rules { + domainRules[i] = domain.SecurityRule{ + Protocol: r.Protocol, + PortRangeMin: r.PortRangeMin, + PortRangeMax: r.PortRangeMax, + RemoteCIDR: r.RemoteCIDR, + } + } + return domainRules +} + +func fromDomainAppInstance(d *domain.AppInstance) AppInstance { + return AppInstance{ + Key: AppInstanceKey{ + Organization: d.Key.Organization, + Name: d.Key.Name, + CloudletKey: CloudletKey{ + Organization: d.Key.CloudletKey.Organization, + Name: d.Key.CloudletKey.Name, + }, + }, + AppKey: AppKey{ + Organization: d.AppKey.Organization, + Name: d.AppKey.Name, + Version: d.AppKey.Version, + }, + Flavor: Flavor{ + Name: d.Flavor.Name, + }, + State: d.State, + PowerState: d.PowerState, + } +} diff --git a/sdk/examples/deploy_app.go b/sdk/examples/deploy_app.go index ccb2c24..5254a79 100644 --- a/sdk/examples/deploy_app.go +++ b/sdk/examples/deploy_app.go @@ -13,6 +13,7 @@ import ( "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" + "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client" ) func main() { @@ -24,34 +25,36 @@ func main() { username := getEnvOrDefault("EDGEXR_USERNAME", "") password := getEnvOrDefault("EDGEXR_PASSWORD", "") - var edgeClient *edgeconnect.Client + var client *edgeconnect_client.Client if token != "" { // Use static token authentication fmt.Println("πŸ” Using Bearer token authentication") - edgeClient = edgeconnect.NewClient(baseURL, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)), - edgeconnect.WithLogger(log.Default()), + client = edgeconnect_client.NewClient(baseURL, + edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)), + edgeconnect_client.WithLogger(log.Default()), ) } else if username != "" && password != "" { // Use username/password authentication (matches existing client pattern) fmt.Println("πŸ” Using username/password authentication") - edgeClient = edgeconnect.NewClientWithCredentials(baseURL, username, password, - edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), - edgeconnect.WithLogger(log.Default()), + client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password, + edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + edgeconnect_client.WithLogger(log.Default()), ) } else { log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD") } + adapter := edgeconnect.NewAdapter(client) + ctx := context.Background() // Example application to deploy - app := &edgeconnect.NewAppInput{ + app := &NewAppInput{ Region: "EU", - App: edgeconnect.App{ - Key: edgeconnect.AppKey{ + App: App{ + Key: AppKey{ Organization: "edp2", Name: "my-edge-app", Version: "1.0.0", @@ -59,21 +62,21 @@ func main() { Deployment: "docker", ImageType: "ImageTypeDocker", ImagePath: "https://registry-1.docker.io/library/nginx:latest", - DefaultFlavor: edgeconnect.Flavor{Name: "EU.small"}, + DefaultFlavor: Flavor{Name: "EU.small"}, ServerlessConfig: struct{}{}, AllowServerless: false, }, } // Demonstrate app lifecycle - if err := demonstrateAppLifecycle(ctx, edgeClient, app); err != nil { + if err := demonstrateAppLifecycle(ctx, adapter, app); err != nil { log.Fatalf("App lifecycle demonstration failed: %v", err) } fmt.Println("βœ… SDK example completed successfully!") } -func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client, input *edgeconnect.NewAppInput) error { +func demonstrateAppLifecycle(ctx context.Context, adapter *edgeconnect.Adapter, input *NewAppInput) error { appKey := input.App.Key region := input.Region var domainAppKey domain.AppKey @@ -96,70 +99,78 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client ServerlessConfig: input.App.ServerlessConfig, AllowServerless: input.App.AllowServerless, } - if err := edgeClient.CreateApp(ctx, input.Region, domainApp); err != nil { + if err := adapter.CreateApp(ctx, input.Region, domainApp); err != nil { return fmt.Errorf("failed to create app: %+v", err) } - fmt.Printf("βœ… App created: %s/%s v%s\n", appKey.Organization, appKey.Name, appKey.Version) + fmt.Println(" βœ… App created successfully.") - // Step 2: Query the application - fmt.Println("\n2. Querying application...") - domainAppKey = domain.AppKey{ - Organization: appKey.Organization, - Name: appKey.Name, - Version: appKey.Version, - } - app, err := edgeClient.ShowApp(ctx, region, domainAppKey) - if err != nil { - return fmt.Errorf("failed to show app: %w", err) - } - fmt.Printf("βœ… App found: %s/%s v%s (deployment: %s)\n", - app.Key.Organization, app.Key.Name, app.Key.Version, app.Deployment) - - // Step 3: List applications in the organization - fmt.Println("\n3. Listing applications...") - filter := domain.AppKey{Organization: appKey.Organization} - apps, err := edgeClient.ShowApps(ctx, region, filter) - if err != nil { - return fmt.Errorf("failed to list apps: %w", err) - } - fmt.Printf("βœ… Found %d applications in organization '%s'\n", len(apps), appKey.Organization) - - // Step 4: Clean up - delete the application - fmt.Println("\n4. Cleaning up...") - domainAppKey = domain.AppKey{ - Organization: appKey.Organization, - Name: appKey.Name, - Version: appKey.Version, - } - if err := edgeClient.DeleteApp(ctx, region, domainAppKey); err != nil { - return fmt.Errorf("failed to delete app: %w", err) - } - fmt.Printf("βœ… App deleted: %s/%s v%s\n", appKey.Organization, appKey.Name, appKey.Version) - - // Step 5: Verify deletion - fmt.Println("\n5. Verifying deletion...") - domainAppKey = domain.AppKey{ - Organization: appKey.Organization, - Name: appKey.Name, - Version: appKey.Version, - } - _, err = edgeClient.ShowApp(ctx, region, domainAppKey) - if err != nil { - if domain.IsNotFoundError(err) { - fmt.Printf("βœ… App successfully deleted (not found)\n") - } else { - return fmt.Errorf("unexpected error verifying deletion: %w", err) + // Defer cleanup to ensure the app is deleted even if subsequent steps fail + defer func() { + fmt.Println("\n4. Cleaning up: Deleting application...") + domainAppKey = domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, } - } else { - return fmt.Errorf("app still exists after deletion") + if err := adapter.DeleteApp(ctx, region, domainAppKey); err != nil { + fmt.Printf(" ⚠️ Cleanup failed: %v\n", err) + } else { + fmt.Println(" βœ… App deleted successfully.") + } + }() + + // Step 2: Verify app creation by fetching it + fmt.Println("\n2. Verifying app creation...") + domainAppKey = domain.AppKey{ + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, } + fetchedApp, err := adapter.ShowApp(ctx, region, domainAppKey) + if err != nil { + return fmt.Errorf("failed to get app after creation: %w", err) + } + fmt.Printf(" βœ… Fetched app: %s/%s v%s\n", + fetchedApp.Key.Organization, fetchedApp.Key.Name, fetchedApp.Key.Version) + + // Step 3: (Placeholder for other operations like updating or deploying) + fmt.Println("\n3. Skipping further operations in this example.") return nil } +// Helper to get environment variables or return a default func getEnvOrDefault(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { + if value, exists := os.LookupEnv(key); exists { return value } return defaultValue } + +// The structs below are included to make the example self-contained and runnable. +// In a real application, these would be defined in the `edgeconnect` package. + +type NewAppInput struct { + Region string + App App +} + +type App struct { + Key AppKey + Deployment string + ImageType string + ImagePath string + DefaultFlavor Flavor + ServerlessConfig interface{} + AllowServerless bool +} + +type AppKey struct { + Organization string + Name string + Version string +} + +type Flavor struct { + Name string +}