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:
Stephan Lo 2025-10-09 00:47:45 +02:00
parent f1ee439c61
commit 7b062612f5
33 changed files with 1426 additions and 3297 deletions

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

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

View file

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

View file

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