refactor(arch): Separate infrastructure from driven adapter
This commit introduces a significant architectural refactoring to decouple the driven adapter from low-level infrastructure concerns, adhering more strictly to the principles of Hexagonal Architecture. Problem: The driven adapter in `internal/adapters/driven/edgeconnect` was responsible for both adapting data structures and handling direct HTTP communication, authentication, and request/response logic. This violated the separation of concerns, making the adapter difficult to test and maintain. Solution: A new infrastructure layer has been created at `internal/infrastructure`. This layer now contains all the low-level details of interacting with the EdgeConnect API. Key Changes: - **New Infrastructure Layer:** Created `internal/infrastructure` to house components that connect to external systems. - **Generic HTTP Client:** A new, generic `edgeconnect_client` was created in `internal/infrastructure/edgeconnect_client`. It is responsible for authentication, making HTTP requests, and handling raw responses. It has no knowledge of the application's domain models. - **Config & Transport Moved:** The `config` and `http` (now `transport`) packages were moved into the infrastructure layer, as they are details of how the application is configured and communicates. - **Consolidated Driven Adapter:** The logic from the numerous old adapter files (`apps.go`, `cloudlet.go`, etc.) has been consolidated into a single, true adapter at `internal/adapters/driven/edgeconnect/adapter.go`. - **Clear Responsibility:** The new `adapter.go` is now solely responsible for: 1. Implementing the driven port (repository) interfaces. 2. Translating domain models into the data structures required by the `edgeconnect_client`. 3. Calling the `edgeconnect_client` to perform the API operations. 4. Translating the results back into domain models. - **Updated Dependency Injection:** The application's entry point (`cmd/cli/main.go`) has been updated to construct and inject dependencies according to the new architecture: `infra_client` -> `adapter` -> `service` -> `cli_command`. - **SDK & Apply Command:** The SDK examples and the `apply` command have been updated to use the new adapter and its repository methods, removing all direct client instantiation.
This commit is contained in:
parent
f1ee439c61
commit
7b062612f5
33 changed files with 1426 additions and 3297 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
747
internal/adapters/driven/edgeconnect/adapter.go
Normal file
747
internal/adapters/driven/edgeconnect/adapter.go
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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...)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
{
|
||||
|
|
@ -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"
|
||||
204
internal/infrastructure/edgeconnect_client/client.go
Normal file
204
internal/infrastructure/edgeconnect_client/client.go
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
23
internal/infrastructure/transport/parser.go
Normal file
23
internal/infrastructure/transport/parser.go
Normal file
|
|
@ -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
|
||||
}
|
||||
137
internal/infrastructure/transport/transport.go
Normal file
137
internal/infrastructure/transport/transport.go
Normal file
|
|
@ -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...)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue