Compare commits

...

9 commits
v2.0.2 ... main

Author SHA1 Message Date
02856be541
fix: Fixed error handling
All checks were successful
test / test (push) Successful in 44s
ci / goreleaser (push) Successful in 1m39s
2025-11-17 15:43:08 +01:00
e38d7e84d5
parseStreamingResponse is now unified for all objects under both versions
All checks were successful
test / test (push) Successful in 45s
2025-11-17 14:40:47 +01:00
2909e0d1b4
feat(api): add nicer error message to format issues indicating permission denied
All checks were successful
test / test (push) Successful in 42s
2025-11-14 12:11:24 +01:00
ece2955a2a
feat(api): Added AppKey to ShowAppInstances
All checks were successful
test / test (push) Successful in 56s
ci / goreleaser (push) Successful in 54s
2025-11-13 16:59:38 +01:00
a51e2ae454
feat(api): Added AppKey property to ShowAppInstances
All checks were successful
test / test (push) Successful in 55s
ci / goreleaser (push) Successful in 1m9s
2025-11-13 16:15:15 +01:00
ece3dddfe6 feat(edge): Added ubuntu buildkit edge v1 (running) and v2 (not running) example
All checks were successful
test / test (push) Successful in 1m10s
2025-10-27 16:32:57 +01:00
9772a072e8 chore(linting): Fixed all linter errors
All checks were successful
test / test (push) Successful in 46s
2025-10-22 12:47:15 +02:00
f3cbfa3723 fix(deploy): Fixed glitch when updating an app inst with an invalid manifest
All checks were successful
test / test (push) Successful in 16s
2025-10-22 10:31:03 +02:00
26ba07200e test(orca-forgjo-runner): added v2 example to deploy forgejo runner in orca
All checks were successful
test / test (push) Successful in 16s
2025-10-21 13:44:33 +02:00
47 changed files with 1055 additions and 490 deletions

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ dist/
### direnv ### ### direnv ###
.direnv .direnv
.envrc .envrc
edge-connect-client

View file

@ -28,7 +28,7 @@ clean:
# Lint the code # Lint the code
lint: lint:
golangci-lint run go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2 run
# Run all checks (generate, test, lint) # Run all checks (generate, test, lint)
check: test lint check: test lint

View file

@ -37,7 +37,7 @@ func validateBaseURL(baseURL string) error {
return fmt.Errorf("user and or password should not be set") return fmt.Errorf("user and or password should not be set")
} }
if !(url.Path == "" || url.Path == "/") { if url.Path != "" && url.Path != "/" {
return fmt.Errorf("should not contain any path '%s'", url.Path) return fmt.Errorf("should not contain any path '%s'", url.Path)
} }
@ -291,12 +291,18 @@ func init() {
cmd.Flags().StringVarP(&appName, "name", "n", "", "application name") cmd.Flags().StringVarP(&appName, "name", "n", "", "application name")
cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version") cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)") cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)")
cmd.MarkFlagRequired("org") if err := cmd.MarkFlagRequired("org"); err != nil {
cmd.MarkFlagRequired("region") panic(err)
}
if err := cmd.MarkFlagRequired("region"); err != nil {
panic(err)
}
} }
// Add required name flag for specific commands // Add required name flag for specific commands
for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} { for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} {
cmd.MarkFlagRequired("name") if err := cmd.MarkFlagRequired("name"); err != nil {
panic(err)
}
} }
} }

View file

@ -31,7 +31,7 @@ the necessary changes to deploy your applications across multiple cloudlets.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if configFile == "" { if configFile == "" {
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n") fmt.Fprintf(os.Stderr, "Error: configuration file is required\n")
cmd.Usage() _ = cmd.Usage()
os.Exit(1) os.Exit(1)
} }
@ -208,20 +208,6 @@ func runApplyV2(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun
return displayDeploymentResults(deployResult) return displayDeploymentResults(deployResult)
} }
type deploymentResult interface {
IsSuccess() bool
GetDuration() string
GetCompletedActions() []actionResult
GetFailedActions() []actionResult
GetError() error
}
type actionResult interface {
GetType() string
GetTarget() string
GetError() error
}
func displayDeploymentResults(result interface{}) error { func displayDeploymentResults(result interface{}) error {
// Use reflection or type assertion to handle both v1 and v2 result types // Use reflection or type assertion to handle both v1 and v2 result types
// For now, we'll use a simple approach that works with both // For now, we'll use a simple approach that works with both
@ -288,7 +274,7 @@ func displayDeploymentResultsV2(deployResult *applyv2.ExecutionResult) error {
func confirmDeployment() bool { func confirmDeployment() bool {
fmt.Print("Do you want to proceed? (yes/no): ") fmt.Print("Do you want to proceed? (yes/no): ")
var response string var response string
fmt.Scanln(&response) _, _ = fmt.Scanln(&response)
switch response { switch response {
case "yes", "y", "YES", "Y": case "yes", "y", "YES", "Y":
@ -305,5 +291,7 @@ func init() {
applyCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without applying them") applyCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without applying them")
applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan") applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan")
applyCmd.MarkFlagRequired("file") if err := applyCmd.MarkFlagRequired("file"); err != nil {
panic(err)
}
} }

View file

@ -31,7 +31,7 @@ Instances are always deleted before the application.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if deleteConfigFile == "" { if deleteConfigFile == "" {
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n") fmt.Fprintf(os.Stderr, "Error: configuration file is required\n")
cmd.Usage() _ = cmd.Usage()
os.Exit(1) os.Exit(1)
} }
@ -273,7 +273,7 @@ func displayDeletionResultsV2(deleteResult *deletev2.DeletionResult) error {
func confirmDeletion() bool { func confirmDeletion() bool {
fmt.Print("Do you want to proceed with deletion? (yes/no): ") fmt.Print("Do you want to proceed with deletion? (yes/no): ")
var response string var response string
fmt.Scanln(&response) _, _ = fmt.Scanln(&response)
switch response { switch response {
case "yes", "y", "YES", "Y": case "yes", "y", "YES", "Y":
@ -290,5 +290,7 @@ func init() {
deleteCmd.Flags().BoolVar(&deleteDryRun, "dry-run", false, "preview deletion without actually deleting resources") deleteCmd.Flags().BoolVar(&deleteDryRun, "dry-run", false, "preview deletion without actually deleting resources")
deleteCmd.Flags().BoolVar(&deleteAutoApprove, "auto-approve", false, "automatically approve the deletion plan") deleteCmd.Flags().BoolVar(&deleteAutoApprove, "auto-approve", false, "automatically approve the deletion plan")
deleteCmd.MarkFlagRequired("file") if err := deleteCmd.MarkFlagRequired("file"); err != nil {
panic(err)
}
} }

View file

@ -15,6 +15,7 @@ var (
cloudletOrg string cloudletOrg string
instanceName string instanceName string
flavorName string flavorName string
appId string
) )
var appInstanceCmd = &cobra.Command{ var appInstanceCmd = &cobra.Command{
@ -104,7 +105,8 @@ var showInstanceCmd = &cobra.Command{
Name: cloudletName, Name: cloudletName,
}, },
} }
instance, err := c.ShowAppInstance(context.Background(), instanceKey, region) appkey := edgeconnect.AppKey{Name: appId}
instance, err := c.ShowAppInstance(context.Background(), instanceKey, appkey, region)
if err != nil { if err != nil {
fmt.Printf("Error showing app instance: %v\n", err) fmt.Printf("Error showing app instance: %v\n", err)
os.Exit(1) os.Exit(1)
@ -120,7 +122,8 @@ var showInstanceCmd = &cobra.Command{
Name: cloudletName, Name: cloudletName,
}, },
} }
instance, err := c.ShowAppInstance(context.Background(), instanceKey, region) appkey := v2.AppKey{Name: appId}
instance, err := c.ShowAppInstance(context.Background(), instanceKey, appkey, region)
if err != nil { if err != nil {
fmt.Printf("Error showing app instance: %v\n", err) fmt.Printf("Error showing app instance: %v\n", err)
os.Exit(1) os.Exit(1)
@ -146,7 +149,8 @@ var listInstancesCmd = &cobra.Command{
Name: cloudletName, Name: cloudletName,
}, },
} }
instances, err := c.ShowAppInstances(context.Background(), instanceKey, region) appKey := edgeconnect.AppKey{Name: appId}
instances, err := c.ShowAppInstances(context.Background(), instanceKey, appKey, region)
if err != nil { if err != nil {
fmt.Printf("Error listing app instances: %v\n", err) fmt.Printf("Error listing app instances: %v\n", err)
os.Exit(1) os.Exit(1)
@ -165,7 +169,8 @@ var listInstancesCmd = &cobra.Command{
Name: cloudletName, Name: cloudletName,
}, },
} }
instances, err := c.ShowAppInstances(context.Background(), instanceKey, region) appKey := v2.AppKey{Name: appId}
instances, err := c.ShowAppInstances(context.Background(), instanceKey, appKey, region)
if err != nil { if err != nil {
fmt.Printf("Error listing app instances: %v\n", err) fmt.Printf("Error listing app instances: %v\n", err)
os.Exit(1) os.Exit(1)
@ -229,18 +234,33 @@ func init() {
cmd.Flags().StringVarP(&cloudletName, "cloudlet", "c", "", "cloudlet name (required)") cmd.Flags().StringVarP(&cloudletName, "cloudlet", "c", "", "cloudlet name (required)")
cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)") cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)")
cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)") cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)")
cmd.Flags().StringVarP(&appId, "app-id", "i", "", "application id")
cmd.MarkFlagRequired("org") if err := cmd.MarkFlagRequired("org"); err != nil {
cmd.MarkFlagRequired("name") panic(err)
cmd.MarkFlagRequired("cloudlet") }
cmd.MarkFlagRequired("cloudlet-org") if err := cmd.MarkFlagRequired("name"); err != nil {
cmd.MarkFlagRequired("region") panic(err)
}
if err := cmd.MarkFlagRequired("cloudlet"); err != nil {
panic(err)
}
if err := cmd.MarkFlagRequired("cloudlet-org"); err != nil {
panic(err)
}
if err := cmd.MarkFlagRequired("region"); err != nil {
panic(err)
}
} }
// Add additional flags for create command // Add additional flags for create command
createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)") createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)")
createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version") createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)") createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)")
createInstanceCmd.MarkFlagRequired("app") if err := createInstanceCmd.MarkFlagRequired("app"); err != nil {
createInstanceCmd.MarkFlagRequired("flavor") panic(err)
}
if err := createInstanceCmd.MarkFlagRequired("flavor"); err != nil {
panic(err)
}
} }

View file

@ -44,19 +44,35 @@ func init() {
rootCmd.PersistentFlags().StringVar(&apiVersion, "api-version", "v2", "API version to use (v1 or v2)") rootCmd.PersistentFlags().StringVar(&apiVersion, "api-version", "v2", "API version to use (v1 or v2)")
rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging") rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging")
viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")) if err := viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")); err != nil {
viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")) panic(err)
viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")) }
viper.BindPFlag("api_version", rootCmd.PersistentFlags().Lookup("api-version")) if err := viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")); err != nil {
panic(err)
}
if err := viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")); err != nil {
panic(err)
}
if err := viper.BindPFlag("api_version", rootCmd.PersistentFlags().Lookup("api-version")); err != nil {
panic(err)
}
} }
func initConfig() { func initConfig() {
viper.AutomaticEnv() viper.AutomaticEnv()
viper.SetEnvPrefix("EDGE_CONNECT") viper.SetEnvPrefix("EDGE_CONNECT")
viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL") if err := viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL"); err != nil {
viper.BindEnv("username", "EDGE_CONNECT_USERNAME") panic(err)
viper.BindEnv("password", "EDGE_CONNECT_PASSWORD") }
viper.BindEnv("api_version", "EDGE_CONNECT_API_VERSION") if err := viper.BindEnv("username", "EDGE_CONNECT_USERNAME"); err != nil {
panic(err)
}
if err := viper.BindEnv("password", "EDGE_CONNECT_PASSWORD"); err != nil {
panic(err)
}
if err := viper.BindEnv("api_version", "EDGE_CONNECT_API_VERSION"); err != nil {
panic(err)
}
if cfgFile != "" { if cfgFile != "" {
viper.SetConfigFile(cfgFile) viper.SetConfigFile(cfgFile)

View file

@ -21,7 +21,7 @@ type EdgeConnectClientInterface interface {
CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error
UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error
DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error
ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error)
CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error
UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error
@ -323,12 +323,7 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
// Extract outbound connections from the app // Extract outbound connections from the app
current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections)) current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections))
for i, conn := range app.RequiredOutboundConnections { for i, conn := range app.RequiredOutboundConnections {
current.OutboundConnections[i] = SecurityRule{ current.OutboundConnections[i] = SecurityRule(conn)
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
} }
return current, nil return current, nil
@ -347,8 +342,11 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire
Name: desired.CloudletName, Name: desired.CloudletName,
}, },
} }
appKey := edgeconnect.AppKey{
Name: desired.AppName,
}
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region) instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -470,7 +468,9 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string,
if err != nil { if err != nil {
return "", fmt.Errorf("failed to open manifest file: %w", err) return "", fmt.Errorf("failed to open manifest file: %w", err)
} }
defer file.Close() defer func() {
_ = file.Close()
}()
hasher := sha256.New() hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil { if _, err := io.Copy(hasher, file); err != nil {
@ -505,18 +505,20 @@ func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) ti
var duration time.Duration var duration time.Duration
// App operations // App operations
if plan.AppAction.Type == ActionCreate { switch plan.AppAction.Type {
case ActionCreate:
duration += 30 * time.Second duration += 30 * time.Second
} else if plan.AppAction.Type == ActionUpdate { case ActionUpdate:
duration += 15 * time.Second duration += 15 * time.Second
} }
// Instance operations (can be done in parallel) // Instance operations (can be done in parallel)
instanceDuration := time.Duration(0) instanceDuration := time.Duration(0)
for _, action := range plan.InstanceActions { for _, action := range plan.InstanceActions {
if action.Type == ActionCreate { switch action.Type {
case ActionCreate:
instanceDuration = max(instanceDuration, 2*time.Minute) instanceDuration = max(instanceDuration, 2*time.Minute)
} else if action.Type == ActionUpdate { case ActionUpdate:
instanceDuration = max(instanceDuration, 1*time.Minute) instanceDuration = max(instanceDuration, 1*time.Minute)
} }
} }

View file

@ -29,7 +29,7 @@ func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.
return args.Get(0).(edgeconnect.App), args.Error(1) return args.Get(0).(edgeconnect.App), args.Error(1)
} }
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) { func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error) {
args := m.Called(ctx, instanceKey, region) args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil { if args.Get(0) == nil {
return edgeconnect.AppInstance{}, args.Error(1) return edgeconnect.AppInstance{}, args.Error(1)
@ -75,14 +75,6 @@ func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect
return args.Get(0).([]edgeconnect.App), args.Error(1) return args.Get(0).([]edgeconnect.App), args.Error(1)
} }
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) {
args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]edgeconnect.AppInstance), args.Error(1)
}
func TestNewPlanner(t *testing.T) { func TestNewPlanner(t *testing.T) {
mockClient := &MockEdgeConnectClient{} mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient) planner := NewPlanner(mockClient)

View file

@ -4,7 +4,9 @@ package v2
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strings"
"time" "time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
@ -204,7 +206,8 @@ func (rm *EdgeConnectResourceManager) RollbackDeployment(ctx context.Context, re
rollbackErrors := []error{} rollbackErrors := []error{}
// Rollback completed instances (in reverse order) // Phase 1: Delete resources that were created in this deployment attempt (in reverse order)
rm.logf("Phase 1: Rolling back created resources")
for i := len(result.CompletedActions) - 1; i >= 0; i-- { for i := len(result.CompletedActions) - 1; i >= 0; i-- {
action := result.CompletedActions[i] action := result.CompletedActions[i]
@ -218,6 +221,32 @@ func (rm *EdgeConnectResourceManager) RollbackDeployment(ctx context.Context, re
} }
} }
// Phase 2: Restore resources that were deleted before the failed deployment
// This is critical for RecreateStrategy which deletes everything before recreating
if result.DeletedAppBackup != nil || len(result.DeletedInstancesBackup) > 0 {
rm.logf("Phase 2: Restoring deleted resources")
// Restore app first (must exist before instances can be created)
if result.DeletedAppBackup != nil {
if err := rm.restoreApp(ctx, result.DeletedAppBackup); err != nil {
rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to restore app: %w", err))
rm.logf("Failed to restore app: %v", err)
} else {
rm.logf("Successfully restored app: %s", result.DeletedAppBackup.App.Key.Name)
}
}
// Restore instances
for _, backup := range result.DeletedInstancesBackup {
if err := rm.restoreInstance(ctx, &backup); err != nil {
rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to restore instance %s: %w", backup.Instance.Key.Name, err))
rm.logf("Failed to restore instance %s: %v", backup.Instance.Key.Name, err)
} else {
rm.logf("Successfully restored instance: %s", backup.Instance.Key.Name)
}
}
}
if len(rollbackErrors) > 0 { if len(rollbackErrors) > 0 {
return fmt.Errorf("rollback encountered %d errors: %v", len(rollbackErrors), rollbackErrors) return fmt.Errorf("rollback encountered %d errors: %v", len(rollbackErrors), rollbackErrors)
} }
@ -278,6 +307,125 @@ func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, acti
return fmt.Errorf("instance action not found for rollback: %s", action.Target) return fmt.Errorf("instance action not found for rollback: %s", action.Target)
} }
// restoreApp recreates an app that was deleted during deployment
func (rm *EdgeConnectResourceManager) restoreApp(ctx context.Context, backup *AppBackup) error {
rm.logf("Restoring app: %s/%s version %s",
backup.App.Key.Organization, backup.App.Key.Name, backup.App.Key.Version)
// Build a clean app input with only creation-safe fields
// We must exclude read-only fields like CreatedAt, UpdatedAt, etc.
appInput := &v2.NewAppInput{
Region: backup.Region,
App: v2.App{
Key: backup.App.Key,
Deployment: backup.App.Deployment,
ImageType: backup.App.ImageType,
ImagePath: backup.App.ImagePath,
AllowServerless: backup.App.AllowServerless,
DefaultFlavor: backup.App.DefaultFlavor,
ServerlessConfig: backup.App.ServerlessConfig,
DeploymentManifest: backup.App.DeploymentManifest,
DeploymentGenerator: backup.App.DeploymentGenerator,
RequiredOutboundConnections: backup.App.RequiredOutboundConnections,
// Explicitly omit read-only fields like CreatedAt, UpdatedAt, Fields, etc.
},
}
if err := rm.client.CreateApp(ctx, appInput); err != nil {
return fmt.Errorf("failed to restore app: %w", err)
}
rm.logf("Successfully restored app: %s", backup.App.Key.Name)
return nil
}
// restoreInstance recreates an instance that was deleted during deployment
func (rm *EdgeConnectResourceManager) restoreInstance(ctx context.Context, backup *InstanceBackup) error {
rm.logf("Restoring instance: %s on %s:%s",
backup.Instance.Key.Name,
backup.Instance.Key.CloudletKey.Organization,
backup.Instance.Key.CloudletKey.Name)
// Build a clean instance input with only creation-safe fields
// We must exclude read-only fields like CloudletLoc, CreatedAt, etc.
instanceInput := &v2.NewAppInstanceInput{
Region: backup.Region,
AppInst: v2.AppInstance{
Key: backup.Instance.Key,
AppKey: backup.Instance.AppKey,
Flavor: backup.Instance.Flavor,
// Explicitly omit read-only fields like CloudletLoc, State, PowerState, CreatedAt, etc.
},
}
// Retry logic to handle namespace termination race conditions
maxRetries := 5
retryDelay := 10 * time.Second
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
rm.logf("Retrying instance restore %s (attempt %d/%d)", backup.Instance.Key.Name, attempt, maxRetries)
select {
case <-time.After(retryDelay):
case <-ctx.Done():
return ctx.Err()
}
}
err := rm.client.CreateAppInstance(ctx, instanceInput)
if err == nil {
rm.logf("Successfully restored instance: %s", backup.Instance.Key.Name)
return nil
}
lastErr = err
// Check if error is retryable
if !rm.isRetryableError(err) {
rm.logf("Failed to restore instance %s: %v (non-retryable error, giving up)", backup.Instance.Key.Name, err)
return fmt.Errorf("failed to restore instance: %w", err)
}
if attempt < maxRetries {
rm.logf("Failed to restore instance %s: %v (will retry)", backup.Instance.Key.Name, err)
}
}
return fmt.Errorf("failed to restore instance after %d attempts: %w", maxRetries+1, lastErr)
}
// isRetryableError determines if an error should be retried
func (rm *EdgeConnectResourceManager) isRetryableError(err error) bool {
if err == nil {
return false
}
errStr := strings.ToLower(err.Error())
// Special case: Kubernetes namespace termination race condition
// This is a transient 400 error that should be retried
if strings.Contains(errStr, "being terminated") || strings.Contains(errStr, "is being terminated") {
return true
}
// Check if it's an APIError with a status code
var apiErr *v2.APIError
if errors.As(err, &apiErr) {
// Don't retry client errors (4xx)
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
return false
}
// Retry server errors (5xx)
if apiErr.StatusCode >= 500 {
return true
}
}
// Retry all other errors (network issues, timeouts, etc.)
return true
}
// logf logs a message if a logger is configured // logf logs a message if a logger is configured
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) { func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
if rm.logger != nil { if rm.logger != nil {

View file

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
@ -464,6 +465,111 @@ func TestRollbackDeploymentFailure(t *testing.T) {
mockClient.AssertExpectations(t) mockClient.AssertExpectations(t)
} }
func TestRollbackDeploymentWithRestore(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan()
// Simulate a RecreateStrategy scenario:
// 1. Old app and instance were deleted and backed up
// 2. New app was created successfully
// 3. New instance creation failed
// 4. Rollback should: delete new app, restore old app, restore old instance
oldApp := v2.App{
Key: v2.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
},
Deployment: "kubernetes",
DeploymentManifest: "old-manifest-content",
}
oldInstance := v2.AppInstance{
Key: v2.AppInstanceKey{
Organization: "test-org",
Name: "test-app-1.0.0-instance",
CloudletKey: v2.CloudletKey{
Organization: "test-cloudlet-org",
Name: "test-cloudlet",
},
},
AppKey: v2.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
},
Flavor: v2.Flavor{Name: "small"},
}
result := &ExecutionResult{
Plan: plan,
// Completed actions: new app was created before failure
CompletedActions: []ActionResult{
{
Type: ActionCreate,
Target: "test-app",
Success: true,
},
},
// Failed action: new instance creation failed
FailedActions: []ActionResult{
{
Type: ActionCreate,
Target: "test-app-1.0.0-instance",
Success: false,
},
},
// Backup of deleted resources
DeletedAppBackup: &AppBackup{
App: oldApp,
Region: "US",
ManifestContent: "old-manifest-content",
},
DeletedInstancesBackup: []InstanceBackup{
{
Instance: oldInstance,
Region: "US",
},
},
}
// Mock rollback operations in order:
// 1. Delete newly created app (rollback create)
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil).Once()
// 2. Restore old app (from backup)
mockClient.On("CreateApp", mock.Anything, mock.MatchedBy(func(input *v2.NewAppInput) bool {
return input.App.Key.Name == "test-app" && input.App.DeploymentManifest == "old-manifest-content"
})).Return(nil).Once()
// 3. Restore old instance (from backup)
mockClient.On("CreateAppInstance", mock.Anything, mock.MatchedBy(func(input *v2.NewAppInstanceInput) bool {
return input.AppInst.Key.Name == "test-app-1.0.0-instance"
})).Return(nil).Once()
ctx := context.Background()
err := manager.RollbackDeployment(ctx, result)
require.NoError(t, err)
mockClient.AssertExpectations(t)
// Verify rollback was logged
assert.Greater(t, len(logger.messages), 0)
// Should have messages about rolling back created resources and restoring deleted resources
hasRestoreLog := false
for _, msg := range logger.messages {
if strings.Contains(msg, "Restoring deleted resources") {
hasRestoreLog = true
break
}
}
assert.True(t, hasRestoreLog, "Should log restoration of deleted resources")
}
func TestConvertNetworkRules(t *testing.T) { func TestConvertNetworkRules(t *testing.T) {
network := &config.NetworkConfig{ network := &config.NetworkConfig{
OutboundConnections: []config.OutboundConnection{ OutboundConnections: []config.OutboundConnection{

View file

@ -21,7 +21,7 @@ type EdgeConnectClientInterface interface {
CreateApp(ctx context.Context, input *v2.NewAppInput) error CreateApp(ctx context.Context, input *v2.NewAppInput) error
UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error
DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error
ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) (v2.AppInstance, error) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) (v2.AppInstance, error)
CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error
UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error
DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error
@ -323,12 +323,7 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
// Extract outbound connections from the app // Extract outbound connections from the app
current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections)) current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections))
for i, conn := range app.RequiredOutboundConnections { for i, conn := range app.RequiredOutboundConnections {
current.OutboundConnections[i] = SecurityRule{ current.OutboundConnections[i] = SecurityRule(conn)
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
} }
return current, nil return current, nil
@ -348,7 +343,9 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire
}, },
} }
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region) appKey := v2.AppKey{Name: desired.AppName}
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -470,7 +467,9 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string,
if err != nil { if err != nil {
return "", fmt.Errorf("failed to open manifest file: %w", err) return "", fmt.Errorf("failed to open manifest file: %w", err)
} }
defer file.Close() defer func() {
_ = file.Close()
}()
hasher := sha256.New() hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil { if _, err := io.Copy(hasher, file); err != nil {
@ -505,18 +504,20 @@ func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) ti
var duration time.Duration var duration time.Duration
// App operations // App operations
if plan.AppAction.Type == ActionCreate { switch plan.AppAction.Type {
case ActionCreate:
duration += 30 * time.Second duration += 30 * time.Second
} else if plan.AppAction.Type == ActionUpdate { case ActionUpdate:
duration += 15 * time.Second duration += 15 * time.Second
} }
// Instance operations (can be done in parallel) // Instance operations (can be done in parallel)
instanceDuration := time.Duration(0) instanceDuration := time.Duration(0)
for _, action := range plan.InstanceActions { for _, action := range plan.InstanceActions {
if action.Type == ActionCreate { switch action.Type {
case ActionCreate:
instanceDuration = max(instanceDuration, 2*time.Minute) instanceDuration = max(instanceDuration, 2*time.Minute)
} else if action.Type == ActionUpdate { case ActionUpdate:
instanceDuration = max(instanceDuration, 1*time.Minute) instanceDuration = max(instanceDuration, 1*time.Minute)
} }
} }

View file

@ -29,7 +29,7 @@ func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, r
return args.Get(0).(v2.App), args.Error(1) return args.Get(0).(v2.App), args.Error(1)
} }
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) (v2.AppInstance, error) { func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) (v2.AppInstance, error) {
args := m.Called(ctx, instanceKey, region) args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil { if args.Get(0) == nil {
return v2.AppInstance{}, args.Error(1) return v2.AppInstance{}, args.Error(1)

View file

@ -159,6 +159,19 @@ func (r *RecreateStrategy) deleteInstancesPhase(ctx context.Context, plan *Deplo
return nil return nil
} }
// Backup instances before deleting them (for rollback restoration)
r.logf("Backing up %d existing instances before deletion", len(instancesToDelete))
for _, action := range instancesToDelete {
backup, err := r.backupInstance(ctx, action, config)
if err != nil {
r.logf("Warning: failed to backup instance %s before deletion: %v", action.InstanceName, err)
// Continue with deletion even if backup fails - this is best effort
} else {
result.DeletedInstancesBackup = append(result.DeletedInstancesBackup, *backup)
r.logf("Backed up instance: %s", action.InstanceName)
}
}
deleteResults := r.executeInstanceActionsWithRetry(ctx, instancesToDelete, "delete", config) deleteResults := r.executeInstanceActionsWithRetry(ctx, instancesToDelete, "delete", config)
for _, deleteResult := range deleteResults { for _, deleteResult := range deleteResults {
@ -172,6 +185,19 @@ func (r *RecreateStrategy) deleteInstancesPhase(ctx context.Context, plan *Deplo
} }
r.logf("Phase 1 complete: deleted %d instances", len(deleteResults)) r.logf("Phase 1 complete: deleted %d instances", len(deleteResults))
// Wait for Kubernetes namespace termination to complete
// This prevents "namespace is being terminated" errors when recreating instances
if len(deleteResults) > 0 {
waitTime := 5 * time.Second
r.logf("Waiting %v for namespace termination to complete...", waitTime)
select {
case <-time.After(waitTime):
case <-ctx.Done():
return ctx.Err()
}
}
return nil return nil
} }
@ -184,6 +210,17 @@ func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentP
r.logf("Phase 2: Deleting existing application") r.logf("Phase 2: Deleting existing application")
// Backup app before deleting it (for rollback restoration)
r.logf("Backing up existing app before deletion")
backup, err := r.backupApp(ctx, plan, config)
if err != nil {
r.logf("Warning: failed to backup app before deletion: %v", err)
// Continue with deletion even if backup fails - this is best effort
} else {
result.DeletedAppBackup = backup
r.logf("Backed up app: %s", plan.AppAction.Desired.Name)
}
appKey := v2.AppKey{ appKey := v2.AppKey{
Organization: plan.AppAction.Desired.Organization, Organization: plan.AppAction.Desired.Organization,
Name: plan.AppAction.Desired.Name, Name: plan.AppAction.Desired.Name,
@ -516,6 +553,54 @@ func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppActi
return true, nil return true, nil
} }
// backupApp fetches and stores the current app state before deletion
func (r *RecreateStrategy) backupApp(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig) (*AppBackup, error) {
appKey := v2.AppKey{
Organization: plan.AppAction.Desired.Organization,
Name: plan.AppAction.Desired.Name,
Version: plan.AppAction.Desired.Version,
}
app, err := r.client.ShowApp(ctx, appKey, plan.AppAction.Desired.Region)
if err != nil {
return nil, fmt.Errorf("failed to fetch app for backup: %w", err)
}
backup := &AppBackup{
App: app,
Region: plan.AppAction.Desired.Region,
ManifestContent: app.DeploymentManifest,
}
return backup, nil
}
// backupInstance fetches and stores the current instance state before deletion
func (r *RecreateStrategy) backupInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (*InstanceBackup, error) {
instanceKey := v2.AppInstanceKey{
Organization: action.Desired.Organization,
Name: action.InstanceName,
CloudletKey: v2.CloudletKey{
Organization: action.Target.CloudletOrg,
Name: action.Target.CloudletName,
},
}
appKey := v2.AppKey{Name: action.Desired.AppName}
instance, err := r.client.ShowAppInstance(ctx, instanceKey, appKey, action.Target.Region)
if err != nil {
return nil, fmt.Errorf("failed to fetch instance for backup: %w", err)
}
backup := &InstanceBackup{
Instance: instance,
Region: action.Target.Region,
}
return backup, nil
}
// logf logs a message if a logger is configured // logf logs a message if a logger is configured
func (r *RecreateStrategy) logf(format string, v ...interface{}) { func (r *RecreateStrategy) logf(format string, v ...interface{}) {
if r.logger != nil { if r.logger != nil {
@ -530,6 +615,14 @@ func isRetryableError(err error) bool {
return false return false
} }
errStr := strings.ToLower(err.Error())
// Special case: Kubernetes namespace termination race condition
// This is a transient 400 error that should be retried
if strings.Contains(errStr, "being terminated") || strings.Contains(errStr, "is being terminated") {
return true
}
// Check if it's an APIError with a status code // Check if it's an APIError with a status code
var apiErr *v2.APIError var apiErr *v2.APIError
if errors.As(err, &apiErr) { if errors.As(err, &apiErr) {

View file

@ -271,6 +271,12 @@ type ExecutionResult struct {
// RollbackSuccess indicates if rollback was successful // RollbackSuccess indicates if rollback was successful
RollbackSuccess bool RollbackSuccess bool
// DeletedAppBackup stores the app that was deleted (for rollback restoration)
DeletedAppBackup *AppBackup
// DeletedInstancesBackup stores instances that were deleted (for rollback restoration)
DeletedInstancesBackup []InstanceBackup
} }
// ActionResult represents the result of executing a single action // ActionResult represents the result of executing a single action
@ -294,6 +300,27 @@ type ActionResult struct {
Details string Details string
} }
// AppBackup stores a deleted app's complete state for rollback restoration
type AppBackup struct {
// App is the full app object that was deleted
App v2.App
// Region where the app was deployed
Region string
// ManifestContent is the deployment manifest content
ManifestContent string
}
// InstanceBackup stores a deleted instance's complete state for rollback restoration
type InstanceBackup struct {
// Instance is the full instance object that was deleted
Instance v2.AppInstance
// Region where the instance was deployed
Region string
}
// IsEmpty returns true if the deployment plan has no actions to perform // IsEmpty returns true if the deployment plan has no actions to perform
func (dp *DeploymentPlan) IsEmpty() bool { func (dp *DeploymentPlan) IsEmpty() bool {
if dp.AppAction.Type != ActionNone { if dp.AppAction.Type != ActionNone {

View file

@ -14,7 +14,7 @@ import (
// EdgeConnectClientInterface defines the methods needed for deletion planning // EdgeConnectClientInterface defines the methods needed for deletion planning
type EdgeConnectClientInterface interface { type EdgeConnectClientInterface interface {
ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error)
ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) ([]edgeconnect.AppInstance, error)
DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error
} }
@ -154,8 +154,9 @@ func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config *
Name: infra.CloudletName, Name: infra.CloudletName,
}, },
} }
appKey := edgeconnect.AppKey{Name: config.Metadata.Name}
instances, err := p.client.ShowAppInstances(ctx, instanceKey, infra.Region) instances, err := p.client.ShowAppInstances(ctx, instanceKey, appKey, infra.Region)
if err != nil { if err != nil {
// If it's a not found error, just continue // If it's a not found error, just continue
if isNotFoundError(err) { if isNotFoundError(err) {

View file

@ -27,7 +27,7 @@ func (m *MockResourceClient) ShowApp(ctx context.Context, appKey v2.AppKey, regi
return args.Get(0).(v2.App), args.Error(1) return args.Get(0).(v2.App), args.Error(1)
} }
func (m *MockResourceClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) { func (m *MockResourceClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) {
args := m.Called(ctx, instanceKey, region) args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Error(1) return nil, args.Error(1)

View file

@ -14,7 +14,7 @@ import (
// EdgeConnectClientInterface defines the methods needed for deletion planning // EdgeConnectClientInterface defines the methods needed for deletion planning
type EdgeConnectClientInterface interface { type EdgeConnectClientInterface interface {
ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error)
ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error)
DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error
DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error
} }
@ -154,8 +154,9 @@ func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config *
Name: infra.CloudletName, Name: infra.CloudletName,
}, },
} }
appKey := v2.AppKey{Name: config.Metadata.Name}
instances, err := p.client.ShowAppInstances(ctx, instanceKey, infra.Region) instances, err := p.client.ShowAppInstances(ctx, instanceKey, appKey, infra.Region)
if err != nil { if err != nil {
// If it's a not found error, just continue // If it's a not found error, just continue
if isNotFoundError(err) { if isNotFoundError(err) {

View file

@ -28,7 +28,7 @@ func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, r
return args.Get(0).(v2.App), args.Error(1) return args.Get(0).(v2.App), args.Error(1)
} }
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) { func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) {
args := m.Called(ctx, instanceKey, region) args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Error(1) return nil, args.Error(1)

View file

@ -23,7 +23,9 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp
if err != nil { if err != nil {
return fmt.Errorf("CreateAppInstance failed: %w", err) return fmt.Errorf("CreateAppInstance failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateAppInstance") return c.handleErrorResponse(resp, "CreateAppInstance")
@ -43,12 +45,12 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp
// ShowAppInstance retrieves a single application instance by key and region // ShowAppInstance retrieves a single application instance by key and region
// Maps to POST /auth/ctrl/ShowAppInst // Maps to POST /auth/ctrl/ShowAppInst
func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) (AppInstance, error) { func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) (AppInstance, error) {
transport := c.getTransport() transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
filter := AppInstanceFilter{ filter := AppInstanceFilter{
AppInstance: AppInstance{Key: appInstKey}, AppInstance: AppInstance{AppKey: appKey, Key: appInstKey},
Region: region, Region: region,
} }
@ -56,7 +58,9 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey,
if err != nil { if err != nil {
return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err) return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return AppInstance{}, fmt.Errorf("app instance %s/%s: %w", return AppInstance{}, fmt.Errorf("app instance %s/%s: %w",
@ -83,12 +87,12 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey,
// ShowAppInstances retrieves all application instances matching the filter criteria // ShowAppInstances retrieves all application instances matching the filter criteria
// Maps to POST /auth/ctrl/ShowAppInst // Maps to POST /auth/ctrl/ShowAppInst
func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, region string) ([]AppInstance, error) { func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) ([]AppInstance, error) {
transport := c.getTransport() transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
filter := AppInstanceFilter{ filter := AppInstanceFilter{
AppInstance: AppInstance{Key: appInstKey}, AppInstance: AppInstance{Key: appInstKey, AppKey: appKey},
Region: region, Region: region,
} }
@ -96,7 +100,9 @@ func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey
if err != nil { if err != nil {
return nil, fmt.Errorf("ShowAppInstances failed: %w", err) return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowAppInstances") return nil, c.handleErrorResponse(resp, "ShowAppInstances")
@ -125,7 +131,9 @@ func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstance
if err != nil { if err != nil {
return fmt.Errorf("UpdateAppInstance failed: %w", err) return fmt.Errorf("UpdateAppInstance failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "UpdateAppInstance") return c.handleErrorResponse(resp, "UpdateAppInstance")
@ -152,7 +160,9 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK
if err != nil { if err != nil {
return fmt.Errorf("RefreshAppInstance failed: %w", err) return fmt.Errorf("RefreshAppInstance failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "RefreshAppInstance") return c.handleErrorResponse(resp, "RefreshAppInstance")
@ -179,7 +189,9 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe
if err != nil { if err != nil {
return fmt.Errorf("DeleteAppInstance failed: %w", err) return fmt.Errorf("DeleteAppInstance failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted) // 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
@ -201,6 +213,10 @@ func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result i
var errorMessage string var errorMessage string
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
// On permission denied, Edge API returns just an empty array []!
if len(line) == 0 || line[0] == '[' {
return fmt.Errorf("%w", ErrFaultyResponsePerhaps403)
}
// Try parsing as ResultResponse first (error format) // Try parsing as ResultResponse first (error format)
var resultResp ResultResponse var resultResp ResultResponse
if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" { if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" {

View file

@ -126,7 +126,7 @@ func TestCreateAppInstance(t *testing.T) {
assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
})) }))
defer server.Close() defer server.Close()
@ -156,6 +156,7 @@ func TestCreateAppInstance(t *testing.T) {
func TestShowAppInstance(t *testing.T) { func TestShowAppInstance(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
appKey AppKey
appInstKey AppInstanceKey appInstKey AppInstanceKey
region string region string
mockStatusCode int mockStatusCode int
@ -173,6 +174,7 @@ func TestShowAppInstance(t *testing.T) {
Name: "testcloudlet", Name: "testcloudlet",
}, },
}, },
appKey: AppKey{Name: "test-app-id"},
region: "us-west", region: "us-west",
mockStatusCode: 200, mockStatusCode: 200,
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}} mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}}
@ -190,6 +192,7 @@ func TestShowAppInstance(t *testing.T) {
Name: "testcloudlet", Name: "testcloudlet",
}, },
}, },
appKey: AppKey{Name: "test-app-id"},
region: "us-west", region: "us-west",
mockStatusCode: 404, mockStatusCode: 404,
mockResponse: "", mockResponse: "",
@ -207,7 +210,7 @@ func TestShowAppInstance(t *testing.T) {
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" { if tt.mockResponse != "" {
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
} }
})) }))
defer server.Close() defer server.Close()
@ -219,7 +222,7 @@ func TestShowAppInstance(t *testing.T) {
// Execute test // Execute test
ctx := context.Background() ctx := context.Background()
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region) appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region)
// Verify results // Verify results
if tt.expectError { if tt.expectError {
@ -254,14 +257,14 @@ func TestShowAppInstances(t *testing.T) {
{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}} {"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}}
` `
w.WriteHeader(200) w.WriteHeader(200)
w.Write([]byte(response)) _, _ = w.Write([]byte(response))
})) }))
defer server.Close() defer server.Close()
client := NewClient(server.URL) client := NewClient(server.URL)
ctx := context.Background() ctx := context.Background()
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, "us-west") appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west")
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, appInstances, 2) assert.Len(t, appInstances, 2)
@ -361,7 +364,7 @@ func TestUpdateAppInstance(t *testing.T) {
assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization) assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization)
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
})) }))
defer server.Close() defer server.Close()

View file

@ -16,6 +16,7 @@ import (
var ( var (
// ErrResourceNotFound indicates the requested resource was not found // ErrResourceNotFound indicates the requested resource was not found
ErrResourceNotFound = fmt.Errorf("resource not found") ErrResourceNotFound = fmt.Errorf("resource not found")
ErrFaultyResponsePerhaps403 = fmt.Errorf("faulty response from API, may indicate permission denied")
) )
// CreateApp creates a new application in the specified region // CreateApp creates a new application in the specified region
@ -28,7 +29,9 @@ func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error {
if err != nil { if err != nil {
return fmt.Errorf("CreateApp failed: %w", err) return fmt.Errorf("CreateApp failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateApp") return c.handleErrorResponse(resp, "CreateApp")
@ -55,7 +58,9 @@ func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App
if err != nil { if err != nil {
return App{}, fmt.Errorf("ShowApp failed: %w", err) return App{}, fmt.Errorf("ShowApp failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
@ -95,7 +100,9 @@ func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]
if err != nil { if err != nil {
return nil, fmt.Errorf("ShowApps failed: %w", err) return nil, fmt.Errorf("ShowApps failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowApps") return nil, c.handleErrorResponse(resp, "ShowApps")
@ -124,7 +131,9 @@ func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error {
if err != nil { if err != nil {
return fmt.Errorf("UpdateApp failed: %w", err) return fmt.Errorf("UpdateApp failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "UpdateApp") return c.handleErrorResponse(resp, "UpdateApp")
@ -151,7 +160,9 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er
if err != nil { if err != nil {
return fmt.Errorf("DeleteApp failed: %w", err) return fmt.Errorf("DeleteApp failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted) // 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
@ -169,6 +180,10 @@ func (c *Client) parseStreamingResponse(resp *http.Response, result interface{})
var responses []Response[App] var responses []Response[App]
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error { parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
// On permission denied, Edge API returns just an empty array []!
if len(line) == 0 || line[0] == '[' {
return fmt.Errorf("%w", ErrFaultyResponsePerhaps403)
}
var response Response[App] var response Response[App]
if err := json.Unmarshal(line, &response); err != nil { if err := json.Unmarshal(line, &response); err != nil {
return err return err
@ -238,7 +253,9 @@ func (c *Client) handleErrorResponse(resp *http.Response, operation string) erro
bodyBytes := []byte{} bodyBytes := []byte{}
if resp.Body != nil { if resp.Body != nil {
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
bodyBytes, _ = io.ReadAll(resp.Body) bodyBytes, _ = io.ReadAll(resp.Body)
messages = append(messages, string(bodyBytes)) messages = append(messages, string(bodyBytes))
} }

View file

@ -67,7 +67,7 @@ func TestCreateApp(t *testing.T) {
assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
})) }))
defer server.Close() defer server.Close()
@ -139,7 +139,7 @@ func TestShowApp(t *testing.T) {
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" { if tt.mockResponse != "" {
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
} }
})) }))
defer server.Close() defer server.Close()
@ -186,7 +186,7 @@ func TestShowApps(t *testing.T) {
{"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}} {"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}}
` `
w.WriteHeader(200) w.WriteHeader(200)
w.Write([]byte(response)) _, _ = w.Write([]byte(response))
})) }))
defer server.Close() defer server.Close()
@ -277,7 +277,7 @@ func TestUpdateApp(t *testing.T) {
assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization) assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization)
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
})) }))
defer server.Close() defer server.Close()
@ -407,13 +407,3 @@ func TestAPIError(t *testing.T) {
assert.Equal(t, 400, err.StatusCode) assert.Equal(t, 400, err.StatusCode)
assert.Len(t, err.Messages, 2) assert.Len(t, err.Messages, 2)
} }
// Helper function to create a test server that handles streaming JSON responses
func createStreamingJSONServer(responses []string, statusCode int) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(statusCode)
for _, response := range responses {
w.Write([]byte(response + "\n"))
}
}))
}

View file

@ -138,7 +138,9 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
// Read response body - same as existing implementation // Read response body - same as existing implementation
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)

View file

@ -56,7 +56,7 @@ func TestUsernamePasswordProvider_Success(t *testing.T) {
// Return token // Return token
response := map[string]string{"token": "dynamic-token-456"} response := map[string]string{"token": "dynamic-token-456"}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
})) }))
defer loginServer.Close() defer loginServer.Close()
@ -75,7 +75,7 @@ func TestUsernamePasswordProvider_LoginFailure(t *testing.T) {
// Mock login server that returns error // Mock login server that returns error
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Invalid credentials")) _, _ = w.Write([]byte("Invalid credentials"))
})) }))
defer loginServer.Close() defer loginServer.Close()
@ -99,7 +99,7 @@ func TestUsernamePasswordProvider_TokenCaching(t *testing.T) {
callCount++ callCount++
response := map[string]string{"token": "cached-token-789"} response := map[string]string{"token": "cached-token-789"}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
})) }))
defer loginServer.Close() defer loginServer.Close()
@ -128,7 +128,7 @@ func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) {
callCount++ callCount++
response := map[string]string{"token": "refreshed-token-999"} response := map[string]string{"token": "refreshed-token-999"}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
})) }))
defer loginServer.Close() defer loginServer.Close()
@ -157,7 +157,7 @@ func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) {
callCount++ callCount++
response := map[string]string{"token": "new-token-after-invalidation"} response := map[string]string{"token": "new-token-after-invalidation"}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
})) }))
defer loginServer.Close() defer loginServer.Close()
@ -185,7 +185,7 @@ func TestUsernamePasswordProvider_BadJSONResponse(t *testing.T) {
// Mock server returning invalid JSON // Mock server returning invalid JSON
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte("invalid json response")) _, _ = w.Write([]byte("invalid json response"))
})) }))
defer loginServer.Close() defer loginServer.Close()

View file

@ -22,7 +22,9 @@ func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) er
if err != nil { if err != nil {
return fmt.Errorf("CreateCloudlet failed: %w", err) return fmt.Errorf("CreateCloudlet failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateCloudlet") return c.handleErrorResponse(resp, "CreateCloudlet")
@ -49,7 +51,9 @@ func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, regi
if err != nil { if err != nil {
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err) return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
@ -89,7 +93,9 @@ func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, reg
if err != nil { if err != nil {
return nil, fmt.Errorf("ShowCloudlets failed: %w", err) return nil, fmt.Errorf("ShowCloudlets failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowCloudlets") return nil, c.handleErrorResponse(resp, "ShowCloudlets")
@ -123,7 +129,9 @@ func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, re
if err != nil { if err != nil {
return fmt.Errorf("DeleteCloudlet failed: %w", err) return fmt.Errorf("DeleteCloudlet failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted) // 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
@ -151,7 +159,9 @@ func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKe
if err != nil { if err != nil {
return nil, fmt.Errorf("GetCloudletManifest failed: %w", err) return nil, fmt.Errorf("GetCloudletManifest failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w", return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w",
@ -189,7 +199,9 @@ func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey Cloud
if err != nil { if err != nil {
return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err) return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w", return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w",

View file

@ -70,7 +70,7 @@ func TestCreateCloudlet(t *testing.T) {
assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
})) }))
defer server.Close() defer server.Close()
@ -140,7 +140,7 @@ func TestShowCloudlet(t *testing.T) {
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" { if tt.mockResponse != "" {
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
} }
})) }))
defer server.Close() defer server.Close()
@ -187,7 +187,7 @@ func TestShowCloudlets(t *testing.T) {
{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}} {"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}}
` `
w.WriteHeader(200) w.WriteHeader(200)
w.Write([]byte(response)) _, _ = w.Write([]byte(response))
})) }))
defer server.Close() defer server.Close()
@ -312,7 +312,7 @@ func TestGetCloudletManifest(t *testing.T) {
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" { if tt.mockResponse != "" {
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
} }
})) }))
defer server.Close() defer server.Close()
@ -380,7 +380,7 @@ func TestGetCloudletResourceUsage(t *testing.T) {
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" { if tt.mockResponse != "" {
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
} }
})) }))
defer server.Close() defer server.Close()

View file

@ -10,8 +10,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
) )
// CreateAppInstance creates a new application instance in the specified region // CreateAppInstance creates a new application instance in the specified region
@ -25,15 +24,16 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp
if err != nil { if err != nil {
return fmt.Errorf("CreateAppInstance failed: %w", err) return fmt.Errorf("CreateAppInstance failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateAppInstance") return c.handleErrorResponse(resp, "CreateAppInstance")
} }
// Parse streaming JSON response // Parse streaming JSON response
var appInstances []AppInstance if _, err = parseStreamingResponse[AppInstance](resp); err != nil {
if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil {
return fmt.Errorf("ShowAppInstance failed to parse response: %w", err) return fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
} }
@ -45,7 +45,7 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp
// ShowAppInstance retrieves a single application instance by key and region // ShowAppInstance retrieves a single application instance by key and region
// Maps to POST /auth/ctrl/ShowAppInst // Maps to POST /auth/ctrl/ShowAppInst
func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) (AppInstance, error) { func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) (AppInstance, error) {
transport := c.getTransport() transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
@ -58,7 +58,9 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey,
if err != nil { if err != nil {
return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err) return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return AppInstance{}, fmt.Errorf("app instance %s/%s: %w", return AppInstance{}, fmt.Errorf("app instance %s/%s: %w",
@ -71,7 +73,7 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey,
// Parse streaming JSON response // Parse streaming JSON response
var appInstances []AppInstance var appInstances []AppInstance
if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { if appInstances, err = parseStreamingResponse[AppInstance](resp); err != nil {
return AppInstance{}, fmt.Errorf("ShowAppInstance failed to parse response: %w", err) return AppInstance{}, fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
} }
@ -85,12 +87,12 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey,
// ShowAppInstances retrieves all application instances matching the filter criteria // ShowAppInstances retrieves all application instances matching the filter criteria
// Maps to POST /auth/ctrl/ShowAppInst // Maps to POST /auth/ctrl/ShowAppInst
func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, region string) ([]AppInstance, error) { func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) ([]AppInstance, error) {
transport := c.getTransport() transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst" url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
filter := AppInstanceFilter{ filter := AppInstanceFilter{
AppInstance: AppInstance{Key: appInstKey}, AppInstance: AppInstance{Key: appInstKey, AppKey: appKey},
Region: region, Region: region,
} }
@ -98,18 +100,20 @@ func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey
if err != nil { if err != nil {
return nil, fmt.Errorf("ShowAppInstances failed: %w", err) return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowAppInstances") return nil, c.handleErrorResponse(resp, "ShowAppInstances")
} }
var appInstances []AppInstance
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return appInstances, nil // Return empty slice for not found return []AppInstance{}, nil // Return empty slice for not found
} }
if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil { var appInstances []AppInstance
if appInstances, err = parseStreamingResponse[AppInstance](resp); err != nil {
return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err) return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err)
} }
@ -127,7 +131,9 @@ func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstance
if err != nil { if err != nil {
return fmt.Errorf("UpdateAppInstance failed: %w", err) return fmt.Errorf("UpdateAppInstance failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "UpdateAppInstance") return c.handleErrorResponse(resp, "UpdateAppInstance")
@ -154,7 +160,9 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK
if err != nil { if err != nil {
return fmt.Errorf("RefreshAppInstance failed: %w", err) return fmt.Errorf("RefreshAppInstance failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "RefreshAppInstance") return c.handleErrorResponse(resp, "RefreshAppInstance")
@ -181,7 +189,9 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe
if err != nil { if err != nil {
return fmt.Errorf("DeleteAppInstance failed: %w", err) return fmt.Errorf("DeleteAppInstance failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted) // 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
@ -195,88 +205,89 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe
} }
// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances // parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances
func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error { func parseStreamingResponse[T Message](resp *http.Response) ([]T, error) {
bodyBytes, err := io.ReadAll(resp.Body) bodyBytes, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return fmt.Errorf("failed to read response body: %w", err) return []T{}, fmt.Errorf("failed to read response body: %w", err)
} }
// Try parsing as a direct JSON array first (v2 API format) // todo finish check the responses, test them, and make a unify result, probably need
switch v := result.(type) { // to update the response parameter to the message type e.g. App or AppInst
case *[]AppInstance: isV2, err := isV2Response(bodyBytes)
var appInstances []AppInstance if err != nil {
if err := json.Unmarshal(bodyBytes, &appInstances); err == nil { return []T{}, fmt.Errorf("failed to parse streaming response: %w", err)
*v = appInstances
return nil
}
} }
// Fall back to streaming format (v1 API format) if isV2 {
var appInstances []AppInstance resultV2, err := parseStreamingResponseV2[T](resp.StatusCode, bodyBytes)
var messages []string if err != nil {
var hasError bool return []T{}, err
var errorCode int
var errorMessage string
parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error {
// Try parsing as ResultResponse first (error format)
var resultResp ResultResponse
if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" {
if resultResp.IsError() {
hasError = true
errorCode = resultResp.GetCode()
errorMessage = resultResp.GetMessage()
} }
return nil return resultV2, nil
} }
// Try parsing as Response[AppInstance] resultV1, err := parseStreamingResponseV1[T](resp.StatusCode, bodyBytes)
var response Response[AppInstance] if err != nil {
if err := json.Unmarshal(line, &response); err != nil { return nil, err
return err
} }
if response.HasData() { if !resultV1.IsSuccessful() {
appInstances = append(appInstances, response.Data) return []T{}, resultV1.Error()
}
if response.IsMessage() {
msg := response.Data.GetMessage()
messages = append(messages, msg)
// Check for error indicators in messages
if msg == "CreateError" || msg == "UpdateError" || msg == "DeleteError" {
hasError = true
}
}
return nil
})
if parseErr != nil {
return parseErr
} }
// If we detected an error, return it return resultV1.GetData(), nil
if hasError { }
apiErr := &APIError{
StatusCode: resp.StatusCode, func parseStreamingResponseV1[T Message](statusCode int, bodyBytes []byte) (Responses[T], error) {
Messages: messages, // Fall back to streaming format (v1 API format)
} var responses Responses[T]
if errorCode > 0 { responses.StatusCode = statusCode
apiErr.StatusCode = errorCode
apiErr.Code = fmt.Sprintf("%d", errorCode) decoder := json.NewDecoder(bytes.NewReader(bodyBytes))
} for {
if errorMessage != "" { var d Response[T]
apiErr.Messages = append([]string{errorMessage}, apiErr.Messages...) if err := decoder.Decode(&d); err != nil {
} if err.Error() == "EOF" {
return apiErr break
} }
return Responses[T]{}, fmt.Errorf("error in parsing json object into Message: %w", err)
// Set result based on type }
switch v := result.(type) {
case *[]AppInstance: if d.Result.Message != "" && d.Result.Code != 0 {
*v = appInstances responses.StatusCode = d.Result.Code
default: }
return fmt.Errorf("unsupported result type: %T", result)
} if strings.Contains(d.Data.GetMessage(), "CreateError") {
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "CreateError"))
return nil }
if strings.Contains(d.Data.GetMessage(), "UpdateError") {
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "UpdateError"))
}
if strings.Contains(d.Data.GetMessage(), "DeleteError") {
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "DeleteError"))
}
responses.Responses = append(responses.Responses, d)
}
return responses, nil
}
func isV2Response(bodyBytes []byte) (bool, error) {
if len(bodyBytes) == 0 {
return false, fmt.Errorf("malformatted response body")
}
return bodyBytes[0] == '[', nil
}
func parseStreamingResponseV2[T Message](statusCode int, bodyBytes []byte) ([]T, error) {
var result []T
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return result, fmt.Errorf("failed to read response body: %w", err)
}
return result, nil
} }

View file

@ -126,7 +126,7 @@ func TestCreateAppInstance(t *testing.T) {
assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
})) }))
defer server.Close() defer server.Close()
@ -157,6 +157,7 @@ func TestShowAppInstance(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
appInstKey AppInstanceKey appInstKey AppInstanceKey
appKey AppKey
region string region string
mockStatusCode int mockStatusCode int
mockResponse string mockResponse string
@ -173,6 +174,7 @@ func TestShowAppInstance(t *testing.T) {
Name: "testcloudlet", Name: "testcloudlet",
}, },
}, },
appKey: AppKey{Name: "testapp"},
region: "us-west", region: "us-west",
mockStatusCode: 200, mockStatusCode: 200,
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}} mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}}
@ -190,6 +192,7 @@ func TestShowAppInstance(t *testing.T) {
Name: "testcloudlet", Name: "testcloudlet",
}, },
}, },
appKey: AppKey{Name: "testapp"},
region: "us-west", region: "us-west",
mockStatusCode: 404, mockStatusCode: 404,
mockResponse: "", mockResponse: "",
@ -207,7 +210,7 @@ func TestShowAppInstance(t *testing.T) {
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" { if tt.mockResponse != "" {
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
} }
})) }))
defer server.Close() defer server.Close()
@ -219,7 +222,7 @@ func TestShowAppInstance(t *testing.T) {
// Execute test // Execute test
ctx := context.Background() ctx := context.Background()
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region) appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region)
// Verify results // Verify results
if tt.expectError { if tt.expectError {
@ -254,14 +257,14 @@ func TestShowAppInstances(t *testing.T) {
{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}} {"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}}
` `
w.WriteHeader(200) w.WriteHeader(200)
w.Write([]byte(response)) _, _ = w.Write([]byte(response))
})) }))
defer server.Close() defer server.Close()
client := NewClient(server.URL) client := NewClient(server.URL)
ctx := context.Background() ctx := context.Background()
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, "us-west") appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west")
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, appInstances, 2) assert.Len(t, appInstances, 2)
@ -361,7 +364,7 @@ func TestUpdateAppInstance(t *testing.T) {
assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization) assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization)
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
})) }))
defer server.Close() defer server.Close()

View file

@ -4,9 +4,7 @@
package v2 package v2
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -29,7 +27,9 @@ func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error {
if err != nil { if err != nil {
return fmt.Errorf("CreateApp failed: %w", err) return fmt.Errorf("CreateApp failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateApp") return c.handleErrorResponse(resp, "CreateApp")
@ -56,7 +56,9 @@ func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App
if err != nil { if err != nil {
return App{}, fmt.Errorf("ShowApp failed: %w", err) return App{}, fmt.Errorf("ShowApp failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w", return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
@ -69,7 +71,7 @@ func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App
// Parse streaming JSON response // Parse streaming JSON response
var apps []App var apps []App
if err := c.parseStreamingResponse(resp, &apps); err != nil { if apps, err = parseStreamingResponse[App](resp); err != nil {
return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err) return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err)
} }
@ -96,18 +98,20 @@ func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]
if err != nil { if err != nil {
return nil, fmt.Errorf("ShowApps failed: %w", err) return nil, fmt.Errorf("ShowApps failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowApps") return nil, c.handleErrorResponse(resp, "ShowApps")
} }
var apps []App
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return apps, nil // Return empty slice for not found return []App{}, nil // Return empty slice for not found
} }
if err := c.parseStreamingResponse(resp, &apps); err != nil { var apps []App
if apps, err = parseStreamingResponse[App](resp); err != nil {
return nil, fmt.Errorf("ShowApps failed to parse response: %w", err) return nil, fmt.Errorf("ShowApps failed to parse response: %w", err)
} }
@ -125,7 +129,9 @@ func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error {
if err != nil { if err != nil {
return fmt.Errorf("UpdateApp failed: %w", err) return fmt.Errorf("UpdateApp failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "UpdateApp") return c.handleErrorResponse(resp, "UpdateApp")
@ -152,7 +158,9 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er
if err != nil { if err != nil {
return fmt.Errorf("DeleteApp failed: %w", err) return fmt.Errorf("DeleteApp failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted) // 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
@ -165,70 +173,6 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er
return nil return nil
} }
// parseStreamingResponse parses the EdgeXR streaming JSON response format
func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error {
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Try parsing as a direct JSON array first (v2 API format)
switch v := result.(type) {
case *[]App:
var apps []App
if err := json.Unmarshal(bodyBytes, &apps); err == nil {
*v = apps
return nil
}
}
// Fall back to streaming format (v1 API format)
var responses []Response[App]
var apps []App
var messages []string
parseErr := sdkhttp.ParseJSONLines(io.NopCloser(bytes.NewReader(bodyBytes)), func(line []byte) error {
var response Response[App]
if err := json.Unmarshal(line, &response); err != nil {
return err
}
responses = append(responses, response)
return nil
})
if parseErr != nil {
return parseErr
}
// Extract data from responses
for _, response := range responses {
if response.HasData() {
apps = append(apps, response.Data)
}
if response.IsMessage() {
messages = append(messages, response.Data.GetMessage())
}
}
// If we have error messages, return them
if len(messages) > 0 {
return &APIError{
StatusCode: resp.StatusCode,
Messages: messages,
}
}
// Set result based on type
switch v := result.(type) {
case *[]App:
*v = apps
default:
return fmt.Errorf("unsupported result type: %T", result)
}
return nil
}
// getTransport creates an HTTP transport with current client settings // getTransport creates an HTTP transport with current client settings
func (c *Client) getTransport() *sdkhttp.Transport { func (c *Client) getTransport() *sdkhttp.Transport {
return sdkhttp.NewTransport( return sdkhttp.NewTransport(
@ -254,7 +198,9 @@ func (c *Client) handleErrorResponse(resp *http.Response, operation string) erro
bodyBytes := []byte{} bodyBytes := []byte{}
if resp.Body != nil { if resp.Body != nil {
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
bodyBytes, _ = io.ReadAll(resp.Body) bodyBytes, _ = io.ReadAll(resp.Body)
messages = append(messages, string(bodyBytes)) messages = append(messages, string(bodyBytes))
} }

View file

@ -67,7 +67,7 @@ func TestCreateApp(t *testing.T) {
assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
})) }))
defer server.Close() defer server.Close()
@ -139,7 +139,7 @@ func TestShowApp(t *testing.T) {
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" { if tt.mockResponse != "" {
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
} }
})) }))
defer server.Close() defer server.Close()
@ -186,7 +186,7 @@ func TestShowApps(t *testing.T) {
{"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}} {"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}}
` `
w.WriteHeader(200) w.WriteHeader(200)
w.Write([]byte(response)) _, _ = w.Write([]byte(response))
})) }))
defer server.Close() defer server.Close()
@ -277,7 +277,7 @@ func TestUpdateApp(t *testing.T) {
assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization) assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization)
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
})) }))
defer server.Close() defer server.Close()
@ -407,13 +407,3 @@ func TestAPIError(t *testing.T) {
assert.Equal(t, 400, err.StatusCode) assert.Equal(t, 400, err.StatusCode)
assert.Len(t, err.Messages, 2) assert.Len(t, err.Messages, 2)
} }
// Helper function to create a test server that handles streaming JSON responses
func createStreamingJSONServer(responses []string, statusCode int) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(statusCode)
for _, response := range responses {
w.Write([]byte(response + "\n"))
}
}))
}

View file

@ -138,7 +138,9 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
// Read response body - same as existing implementation // Read response body - same as existing implementation
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)

View file

@ -56,7 +56,7 @@ func TestUsernamePasswordProvider_Success(t *testing.T) {
// Return token // Return token
response := map[string]string{"token": "dynamic-token-456"} response := map[string]string{"token": "dynamic-token-456"}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
})) }))
defer loginServer.Close() defer loginServer.Close()
@ -75,7 +75,7 @@ func TestUsernamePasswordProvider_LoginFailure(t *testing.T) {
// Mock login server that returns error // Mock login server that returns error
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Invalid credentials")) _, _ = w.Write([]byte("Invalid credentials"))
})) }))
defer loginServer.Close() defer loginServer.Close()
@ -99,7 +99,7 @@ func TestUsernamePasswordProvider_TokenCaching(t *testing.T) {
callCount++ callCount++
response := map[string]string{"token": "cached-token-789"} response := map[string]string{"token": "cached-token-789"}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
})) }))
defer loginServer.Close() defer loginServer.Close()
@ -128,7 +128,7 @@ func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) {
callCount++ callCount++
response := map[string]string{"token": "refreshed-token-999"} response := map[string]string{"token": "refreshed-token-999"}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
})) }))
defer loginServer.Close() defer loginServer.Close()
@ -157,7 +157,7 @@ func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) {
callCount++ callCount++
response := map[string]string{"token": "new-token-after-invalidation"} response := map[string]string{"token": "new-token-after-invalidation"}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
})) }))
defer loginServer.Close() defer loginServer.Close()
@ -185,7 +185,7 @@ func TestUsernamePasswordProvider_BadJSONResponse(t *testing.T) {
// Mock server returning invalid JSON // Mock server returning invalid JSON
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte("invalid json response")) _, _ = w.Write([]byte("invalid json response"))
})) }))
defer loginServer.Close() defer loginServer.Close()

View file

@ -22,7 +22,9 @@ func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) er
if err != nil { if err != nil {
return fmt.Errorf("CreateCloudlet failed: %w", err) return fmt.Errorf("CreateCloudlet failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateCloudlet") return c.handleErrorResponse(resp, "CreateCloudlet")
@ -49,7 +51,9 @@ func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, regi
if err != nil { if err != nil {
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err) return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w", return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
@ -89,7 +93,9 @@ func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, reg
if err != nil { if err != nil {
return nil, fmt.Errorf("ShowCloudlets failed: %w", err) return nil, fmt.Errorf("ShowCloudlets failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowCloudlets") return nil, c.handleErrorResponse(resp, "ShowCloudlets")
@ -123,7 +129,9 @@ func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, re
if err != nil { if err != nil {
return fmt.Errorf("DeleteCloudlet failed: %w", err) return fmt.Errorf("DeleteCloudlet failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted) // 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound { if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
@ -151,7 +159,9 @@ func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKe
if err != nil { if err != nil {
return nil, fmt.Errorf("GetCloudletManifest failed: %w", err) return nil, fmt.Errorf("GetCloudletManifest failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w", return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w",
@ -189,7 +199,9 @@ func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey Cloud
if err != nil { if err != nil {
return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err) return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w", return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w",

View file

@ -70,7 +70,7 @@ func TestCreateCloudlet(t *testing.T) {
assert.Equal(t, "application/json", r.Header.Get("Content-Type")) assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
})) }))
defer server.Close() defer server.Close()
@ -140,7 +140,7 @@ func TestShowCloudlet(t *testing.T) {
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" { if tt.mockResponse != "" {
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
} }
})) }))
defer server.Close() defer server.Close()
@ -187,7 +187,7 @@ func TestShowCloudlets(t *testing.T) {
{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}} {"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}}
` `
w.WriteHeader(200) w.WriteHeader(200)
w.Write([]byte(response)) _, _ = w.Write([]byte(response))
})) }))
defer server.Close() defer server.Close()
@ -312,7 +312,7 @@ func TestGetCloudletManifest(t *testing.T) {
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" { if tt.mockResponse != "" {
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
} }
})) }))
defer server.Close() defer server.Close()
@ -380,7 +380,7 @@ func TestGetCloudletResourceUsage(t *testing.T) {
w.WriteHeader(tt.mockStatusCode) w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" { if tt.mockResponse != "" {
w.Write([]byte(tt.mockResponse)) _, _ = w.Write([]byte(tt.mockResponse))
} }
})) }))
defer server.Close() defer server.Close()

View file

@ -291,6 +291,7 @@ type DeleteAppInstanceInput struct {
// Response wraps a single API response // Response wraps a single API response
type Response[T Message] struct { type Response[T Message] struct {
ResultResponse `json:",inline"`
Data T `json:"data"` Data T `json:"data"`
} }
@ -326,6 +327,7 @@ func (r *ResultResponse) GetCode() int {
type Responses[T Message] struct { type Responses[T Message] struct {
Responses []Response[T] `json:"responses,omitempty"` Responses []Response[T] `json:"responses,omitempty"`
StatusCode int `json:"-"` StatusCode int `json:"-"`
Errors []error `json:"-"`
} }
func (r *Responses[T]) GetData() []T { func (r *Responses[T]) GetData() []T {
@ -344,12 +346,15 @@ func (r *Responses[T]) GetMessages() []string {
if v.IsMessage() { if v.IsMessage() {
messages = append(messages, v.Data.GetMessage()) messages = append(messages, v.Data.GetMessage())
} }
if v.Result.Message != "" {
messages = append(messages, v.Result.Message)
}
} }
return messages return messages
} }
func (r *Responses[T]) IsSuccessful() bool { func (r *Responses[T]) IsSuccessful() bool {
return r.StatusCode >= 200 && r.StatusCode < 400 return len(r.Errors) == 0 && (r.StatusCode >= 200 && r.StatusCode < 400)
} }
func (r *Responses[T]) Error() error { func (r *Responses[T]) Error() error {
@ -410,3 +415,7 @@ type CloudletResourceUsage struct {
Region string `json:"region"` Region string `json:"region"`
Usage map[string]interface{} `json:"usage"` Usage map[string]interface{} `json:"usage"`
} }
type ErrorMessage struct {
Message string
}

View file

@ -18,6 +18,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: edgeconnect-coder-deployment name: edgeconnect-coder-deployment
#namespace: gitea
spec: spec:
replicas: 1 replicas: 1
selector: selector:

View file

@ -193,7 +193,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow
}, },
} }
instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, config.Region, 5*time.Minute) instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, v2.AppKey{}, config.Region, 5*time.Minute)
if err != nil { if err != nil {
return fmt.Errorf("failed to wait for instance ready: %w", err) return fmt.Errorf("failed to wait for instance ready: %w", err)
} }
@ -207,7 +207,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config Workflow
// 6. List Application Instances // 6. List Application Instances
fmt.Println("\n6⃣ Listing application instances...") fmt.Println("\n6⃣ Listing application instances...")
instances, err := c.ShowAppInstances(ctx, v2.AppInstanceKey{Organization: config.Organization}, config.Region) instances, err := c.ShowAppInstances(ctx, v2.AppInstanceKey{Organization: config.Organization}, v2.AppKey{}, config.Region)
if err != nil { if err != nil {
return fmt.Errorf("failed to list app instances: %w", err) return fmt.Errorf("failed to list app instances: %w", err)
} }
@ -306,7 +306,7 @@ func getEnvOrDefault(key, defaultValue string) string {
} }
// waitForInstanceReady polls the instance status until it's no longer "Creating" or timeout // waitForInstanceReady polls the instance status until it's no longer "Creating" or timeout
func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppInstanceKey, region string, timeout time.Duration) (v2.AppInstance, error) { func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string, timeout time.Duration) (v2.AppInstance, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, timeout) timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
@ -321,7 +321,7 @@ func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppI
return v2.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout) return v2.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout)
case <-ticker.C: case <-ticker.C:
instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, region) instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, appKey, region)
if err != nil { if err != nil {
// Log error but continue polling // Log error but continue polling
fmt.Printf(" ⚠️ Error checking instance state: %v\n", err) fmt.Printf(" ⚠️ Error checking instance state: %v\n", err)

View file

@ -0,0 +1,29 @@
# Is there a swagger file for the new EdgeConnect API?
# How does it differ from the EdgeXR API?
kind: edgeconnect-deployment
metadata:
name: "forgejo-runner-orca" # name could be used for appName
appVersion: "1"
organization: "edp2-orca"
spec:
# dockerApp: # Docker is OBSOLETE
# appVersion: "1.0.0"
# manifestFile: "./docker-compose.yaml"
# image: "https://registry-1.docker.io/library/nginx:latest"
k8sApp:
manifestFile: "./forgejo-runner-deployment.yaml"
infraTemplate:
- region: "US"
cloudletOrg: "TelekomOp"
cloudletName: "gardener-shepherd-test"
flavorName: "defualt"
network:
outboundConnections:
- protocol: "tcp"
portRangeMin: 80
portRangeMax: 80
remoteCIDR: "0.0.0.0/0"
- protocol: "tcp"
portRangeMin: 443
portRangeMax: 443
remoteCIDR: "0.0.0.0/0"

View file

@ -0,0 +1,29 @@
# Is there a swagger file for the new EdgeConnect API?
# How does it differ from the EdgeXR API?
kind: edgeconnect-deployment
metadata:
name: "edge-ubuntu-buildkit" # name could be used for appName
appVersion: "1.0.0"
organization: "edp2"
spec:
# dockerApp: # Docker is OBSOLETE
# appVersion: "1.0.0"
# manifestFile: "./docker-compose.yaml"
# image: "https://registry-1.docker.io/library/nginx:latest"
k8sApp:
manifestFile: "./k8s-deployment.yaml"
infraTemplate:
- region: "EU"
cloudletOrg: "TelekomOP"
cloudletName: "Munich"
flavorName: "EU.small"
network:
outboundConnections:
- protocol: "tcp"
portRangeMin: 80
portRangeMax: 80
remoteCIDR: "0.0.0.0/0"
- protocol: "tcp"
portRangeMin: 443
portRangeMax: 443
remoteCIDR: "0.0.0.0/0"

View file

@ -0,0 +1,29 @@
# Is there a swagger file for the new EdgeConnect API?
# How does it differ from the EdgeXR API?
kind: edgeconnect-deployment
metadata:
name: "edge-ubuntu-buildkit" # name could be used for appName
appVersion: "1"
organization: "edp2-orca"
spec:
# dockerApp: # Docker is OBSOLETE
# appVersion: "1.0.0"
# manifestFile: "./docker-compose.yaml"
# image: "https://registry-1.docker.io/library/nginx:latest"
k8sApp:
manifestFile: "./k8s-deployment.yaml"
infraTemplate:
- region: "US"
cloudletOrg: "TelekomOp"
cloudletName: "gardener-shepherd-test"
flavorName: "defualt"
network:
outboundConnections:
- protocol: "tcp"
portRangeMin: 80
portRangeMax: 80
remoteCIDR: "0.0.0.0/0"
- protocol: "tcp"
portRangeMin: 443
portRangeMax: 443
remoteCIDR: "0.0.0.0/0"

View file

@ -0,0 +1,57 @@
# Add remote buildx builder:
# docker buildx create --use --name sidecar tcp://127.0.0.1:1234
# Run build:
# docker buildx build .
apiVersion: v1
kind: Service
metadata:
name: ubuntu-runner
labels:
run: ubuntu-runner
spec:
type: LoadBalancer
ports:
- name: tcp80
protocol: TCP
port: 80
targetPort: 80
selector:
run: ubuntu-runner
---
apiVersion: apps/v1
kind: Deployment
metadata:
creationTimestamp: null
labels:
app: ubuntu-runner
name: ubuntu-runner
spec:
replicas: 1
selector:
matchLabels:
app: ubuntu-runner
strategy: {}
template:
metadata:
creationTimestamp: null
labels:
app: ubuntu-runner
annotations:
container.apparmor.security.beta.kubernetes.io/buildkitd: unconfined
spec:
containers:
- name: ubuntu
image: edp.buildth.ing/devfw-cicd/catthehacker/ubuntu:act-22.04-amd64
command:
- sleep
- 7d
- args:
- --allow-insecure-entitlement=network.host
- --oci-worker-no-process-sandbox
- --addr
- tcp://127.0.0.1:1234
image: moby/buildkit:v0.25.1-rootless
imagePullPolicy: IfNotPresent
name: buildkitd

View file

@ -162,7 +162,9 @@ func (t *Transport) CallJSON(ctx context.Context, method, url string, body inter
if err != nil { if err != nil {
return resp, err return resp, err
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
// Read response body // Read response body
respBody, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)