Compare commits

..

16 commits

Author SHA1 Message Date
488fe430fb fix(apply): fixed client logic ... responses are now filtered as before 2025-10-09 17:05:27 +02:00
00487bec7c fix(apply): first hexagonal refactoring mismatched AppTypes 2025-10-09 13:58:45 +02:00
054b1c91fc fix(refactor): added binaries to git ignore 2025-10-09 11:10:51 +02:00
5918ba5db6 fix(refactor): aligned all env vars 2025-10-09 11:08:19 +02:00
5ac67a224d fix: resolve all golangci-lint errors
- Fix errcheck errors by properly handling resp.Body.Close() return values
- Fix staticcheck ST1005 errors by uncapitalizing error messages
- Remove unused orgName variable
- Wrap all deferred Close() calls in anonymous functions to handle errors
2025-10-09 10:54:14 +02:00
1c13c93512 refactor(arch): Relocate domain and ports packages
Moves the `domain` and `ports` packages from `internal/core` to `internal`.

This refactoring simplifies the directory structure by elevating the core architectural concepts of domain and ports to the top level of the `internal` directory. The `core` directory is now removed as its only purpose was to house these two packages.

All import paths across the project have been updated to reflect this change.
2025-10-09 01:16:31 +02:00
a987e42ad6 refactor(domain): Decompose domain.go into individual entity files
Decomposes the monolithic `internal/core/domain/domain.go` file into separate files for each domain entity (`app.go`, `app_instance.go`, `cloudlet.go`, `flavor.go`).

- `SecurityRule` struct moved into `app.go` as it is a value object specific to the App entity.
- `Location` struct moved into `cloudlet.go` as it is a value object specific to the Cloudlet entity.
- `Flavor` remains a separate file as it is a shared entity used by App, AppInstance, and Cloudlet.

This refactoring improves modularity and makes the domain model easier to navigate and understand.
2025-10-09 01:09:36 +02:00
8d6f51978d feat(organization): WiP - Add organization management
Adds the core functionality for managing organizations, including the domain, application service, repository, and CLI adapter.

The `organization` command and its subcommands (`create`, `show`, `update`, `delete`) are now available.

Note: The `show` command is currently not working as expected due to an API issue where it does not find the newly created organization. This is marked as a work in progress.
2025-10-09 01:01:56 +02:00
7b062612f5 refactor(arch): Separate infrastructure from driven adapter
This commit introduces a significant architectural refactoring to decouple the driven adapter from low-level infrastructure concerns, adhering more strictly to the principles of Hexagonal Architecture.

Problem:
The driven adapter in `internal/adapters/driven/edgeconnect` was responsible for both adapting data structures and handling direct HTTP communication, authentication, and request/response logic. This violated the separation of concerns, making the adapter difficult to test and maintain.

Solution:
A new infrastructure layer has been created at `internal/infrastructure`. This layer now contains all the low-level details of interacting with the EdgeConnect API.

Key Changes:
- **New Infrastructure Layer:** Created `internal/infrastructure` to house components that connect to external systems.
- **Generic HTTP Client:** A new, generic `edgeconnect_client` was created in `internal/infrastructure/edgeconnect_client`. It is responsible for authentication, making HTTP requests, and handling raw responses. It has no knowledge of the application's domain models.
- **Config & Transport Moved:** The `config` and `http` (now `transport`) packages were moved into the infrastructure layer, as they are details of how the application is configured and communicates.
- **Consolidated Driven Adapter:** The logic from the numerous old adapter files (`apps.go`, `cloudlet.go`, etc.) has been consolidated into a single, true adapter at `internal/adapters/driven/edgeconnect/adapter.go`.
- **Clear Responsibility:** The new `adapter.go` is now solely responsible for:
  1. Implementing the driven port (repository) interfaces.
  2. Translating domain models into the data structures required by the `edgeconnect_client`.
  3. Calling the `edgeconnect_client` to perform the API operations.
  4. Translating the results back into domain models.
- **Updated Dependency Injection:** The application's entry point (`cmd/cli/main.go`) has been updated to construct and inject dependencies according to the new architecture: `infra_client` -> `adapter` -> `service` -> `cli_command`.
- **SDK & Apply Command:** The SDK examples and the `apply` command have been updated to use the new adapter and its repository methods, removing all direct client instantiation.
2025-10-09 00:47:45 +02:00
f1ee439c61 refactor: structure core logic by application use cases
Restructures the internal business logic from a generic `services` package to a use-case-driven design under `internal/application`.

Each primary function of the application (`app`, `instance`, `cloudlet`, `apply`) now resides in its own package. This clarifies the architecture and makes it easier to navigate and extend.

- Moved service implementations to `internal/application/<usecase>/`.
- Kept ports and domain models in `internal/core/`.
- Updated `main.go` and CLI adapters to reflect the new paths.
- Added missing `RefreshAppInstance` method to satisfy the service interface.
- Verified the change with a full build and test run.
2025-10-09 00:00:51 +02:00
19a9807499 fix: resolve all 27 golangci-lint issues with comprehensive error handling
🔧 Code Quality Improvements:
- Complete errcheck compliance (24 issues → 0)
- staticcheck optimizations (2 issues → 0)
- Unused code cleanup (1 issue → 0)
- Production-ready error handling across codebase

📦 Production Code Fixes (Priority 1):
- resp.Body.Close(): Proper defer functions with error logging
- cmd.MarkFlagRequired(): Panic on setup-critical flag errors
- viper.BindPFlag/BindEnv(): Panic on configuration binding failures
- file.Close(): Warning logs for file handling errors
- fmt.Scanln/cmd.Usage(): Graceful error handling in CLI

🧪 Test Code Fixes (Priority 2):
- w.Write(): Error checking in all HTTP mock servers
- json.NewEncoder().Encode(): Proper error handling in test helpers
- Robust test infrastructure without silent failures

 Performance & Readability (staticcheck):
- if-else chains → tagged switch statements in planner.go
- Empty branch elimination with meaningful error logging
- Import cleanup after unused function removal

🗂️ Code Organization:
- Removed unused createStreamingJSONServer helper function
- Clean imports without unused dependencies
- Consistent error handling patterns across adapters

 Quality Assurance:
- make lint: 27 issues → 0 issues
- All tests passing with robust error handling
- Production-ready error management and logging
- Enhanced code maintainability and debugging

🎯 Impact:
- Eliminates resource leaks from unclosed HTTP bodies
- Prevents silent failures in CLI setup and configuration
- Improves debugging with comprehensive error logging
- Enhances test reliability and error visibility
2025-10-08 18:55:31 +02:00
8e2e61d61e feat: implement dependency injection with proper hexagonal architecture
 Features:
- Simple dependency inversion following SOLID principles
- Clean constructor injection without complex DI containers
- Proper hexagonal architecture with driving/driven separation
- Presentation layer moved to cmd/cli for correct application structure

🏗️ Architecture Changes:
- Driving Adapters (Inbound): internal/adapters/driving/cli/
- Driven Adapters (Outbound): internal/adapters/driven/edgeconnect/
- Core Services: Dependency-injected via interface parameters
- main.go relocated from root to cmd/cli/main.go

📦 Application Flow:
1. cmd/cli/main.go - Entry point and dependency wiring
   └── Creates EdgeConnect client based on environment
   └── Instantiates services with injected repositories
   └── Executes CLI with properly wired dependencies

2. internal/adapters/driving/cli/ - User interface layer
   └── Receives user commands and input validation
   └── Delegates to core services via driving ports
   └── Handles presentation logic and output formatting

3. internal/core/services/ - Business logic layer
   └── NewAppService(appRepo, instanceRepo) - Constructor injection
   └── NewAppInstanceService(instanceRepo) - Interface dependencies
   └── NewCloudletService(cloudletRepo) - Clean separation

4. internal/adapters/driven/edgeconnect/ - Infrastructure layer
   └── Implements repository interfaces for external API
   └── Handles HTTP communication and data persistence
   └── Provides concrete implementations of driven ports

🔧 Build & Deployment:
- CLI Binary: make build → bin/edge-connect-cli
- Usage: ./bin/edge-connect-cli --help
- Tests: make test (all passing)
- Clean: make clean (updated paths)

💡 Benefits:
- Simple and maintainable dependency management
- Testable architecture with clear boundaries
- SOLID principles compliance without overengineering
- Proper separation of concerns in hexagonal structure
2025-10-08 18:15:26 +02:00
2625a58691 feat: implement unified domain error handling system
Addresses Verbesserungspotential 2 (Error Handling uneinheitlich) by introducing
a comprehensive, structured error handling approach across all architectural layers.

## New Domain Error System
- Add ErrorCode enum with 15 semantic error types (NOT_FOUND, VALIDATION_FAILED, etc.)
- Implement DomainError struct with operation context, resource identifiers, and regions
- Create resource-specific error constructors (NewAppError, NewInstanceError, NewCloudletError)
- Add utility functions for error type checking (IsNotFoundError, IsValidationError, etc.)

## Service Layer Enhancements
- Replace generic fmt.Errorf with structured domain errors in all services
- Add comprehensive validation functions for App, AppInstance, and Cloudlet entities
- Implement business logic validation with meaningful error context
- Ensure consistent error semantics across app_service, instance_service, cloudlet_service

## Adapter Layer Updates
- Update EdgeConnect adapters to use domain errors instead of error constants
- Enhance CLI adapter with domain-specific error checking for better UX
- Fix SDK examples to use new IsNotFoundError() approach
- Maintain backward compatibility where possible

## Test Coverage
- Add comprehensive error_test.go with 100% coverage of new error system
- Update existing adapter tests to validate domain error types
- All tests passing with proper error type assertions

## Benefits
-  Consistent error handling across all architectural layers
-  Rich error context with operation, resource, and region information
-  Type-safe error checking with semantic error codes
-  Better user experience with domain-specific error messages
-  Maintainable centralized error definitions
-  Full hexagonal architecture compliance

Files modified: 12 files updated, 2 new files added
Tests: All passing (29+ test cases with enhanced error validation)
2025-10-08 16:52:36 +02:00
7b359f81e3 chore(): unique go version 1.25, fixes 'make test-coverage' error 2025-10-08 16:49:31 +02:00
e72c81bc43 fix(test): fixed by ai all tests after refactoring 2025-10-08 13:35:49 +02:00
43d8f277a6 feat(arch) added hexagonal arch impl done with ai 2025-10-08 12:55:53 +02:00
112 changed files with 5047 additions and 14950 deletions

View file

@ -1,14 +0,0 @@
# Example EdgeConnect CLI Configuration File
# Place this file at ~/.edge-connect.yaml or specify with --config flag
# Base URL for the EdgeConnect API
base_url: "https://hub.apps.edge.platform.mg3.mdb.osc.live"
# Authentication credentials
username: "your-username@example.com"
password: "your-password"
# API version to use (v1 or v2)
# Default: v2
# Set via config, --api-version flag, or EDGE_CONNECT_API_VERSION env var
api_version: "v2"

View file

@ -19,16 +19,9 @@ jobs:
go-version: ">=1.25.1" go-version: ">=1.25.1"
- name: Test code - name: Test code
run: make test run: make test
- name: Import GPG key
id: import_gpg
uses: https://github.com/crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Run GoReleaser - name: Run GoReleaser
uses: https://github.com/goreleaser/goreleaser-action@v6 uses: https://github.com/goreleaser/goreleaser-action@v6
env: env:
GITEA_TOKEN: ${{ secrets.PACKAGES_TOKEN }} GITEA_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
with: with:
args: release --clean args: release --clean

6
.gitignore vendored
View file

@ -2,8 +2,10 @@ edge-connect
# Added by goreleaser init: # Added by goreleaser init:
dist/ dist/
# ignore binaries
main
bin/
### direnv ### ### direnv ###
.direnv .direnv
.envrc .envrc
edge-connect-client

View file

@ -31,18 +31,6 @@ archives:
- goos: windows - goos: windows
formats: [zip] formats: [zip]
signs:
- artifacts: checksum
cmd: gpg
args:
- "--batch"
- "-u"
- "{{ .Env.GPG_FINGERPRINT }}"
- "--output"
- "${signature}"
- "--detach-sign"
- "${artifact}"
changelog: changelog:
abbrev: 10 abbrev: 10
filters: filters:

View file

@ -13,22 +13,22 @@ test:
# Run tests with coverage # Run tests with coverage
test-coverage: test-coverage:
go test -v -coverprofile=coverage.out ./... GOTOOLCHAIN=go1.25.1 go test -v -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html GOTOOLCHAIN=go1.25.1 go tool cover -html=coverage.out -o coverage.html
# Build the CLI # Build the CLI
build: build:
go build -o bin/edge-connect . go build -o bin/edge-connect-cli ./cmd/cli
# Clean generated files and build artifacts # Clean generated files and build artifacts
clean: clean:
rm -f sdk/client/types_generated.go rm -f sdk/client/types_generated.go
rm -f bin/edge-connect rm -f bin/edge-connect-cli
rm -f coverage.out coverage.html rm -f coverage.out coverage.html
# Lint the code # Lint the code
lint: lint:
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2 run golangci-lint run
# Run all checks (generate, test, lint) # Run all checks (generate, test, lint)
check: test lint check: test lint

View file

@ -1,868 +0,0 @@
{
"basePath": "/api/v1",
"definitions": {
"handler.App": {
"properties": {
"access_ports": {
"description": "Application port to be exposed with ingress in format <protocol:port>.\nNecessary only when manifest is generated automatically. Otherwise, all\nthe ports has to be set up manually in YAML manifest.",
"example": "tcp:80",
"type": "string"
},
"allow_serverless": {
"type": "boolean"
},
"created_at": {
"description": "Timestamp, format RFC3339.",
"type": "string"
},
"defaultFlavor": {
"allOf": [
{
"$ref": "#/definitions/handler.Flavor"
}
],
"description": "Default resource config to be used."
},
"deployment": {
"allOf": [
{
"$ref": "#/definitions/handler.DeploymentType"
}
],
"example": "kubernetes"
},
"global_id": {
"description": "Combination of key fields (local-<key.name><key.version><key.organisation>).",
"type": "string"
},
"image_path": {
"description": "Docker registry URL (e.g. docker.io/library/nginx:latest)",
"type": "string"
},
"image_type": {
"$ref": "#/definitions/handler.ImageType"
},
"key": {
"$ref": "#/definitions/handler.AppKey"
},
"serverless_config": {
"$ref": "#/definitions/handler.ServerlessConfig"
},
"updated_at": {
"description": "Timestamp, format RFC3339.",
"type": "string"
}
},
"type": "object"
},
"handler.AppInst": {
"properties": {
"app_key": {
"$ref": "#/definitions/handler.AppKey"
},
"cloudlet_loc": {
"$ref": "#/definitions/handler.CloudletLoc"
},
"created_at": {
"type": "string"
},
"flavor": {
"$ref": "#/definitions/handler.Flavor"
},
"ingress_url": {
"type": "string"
},
"key": {
"$ref": "#/definitions/handler.AppInstKey"
},
"state": {
"$ref": "#/definitions/handler.AppInstState"
},
"unique_id": {
"description": "Combination of key fields (<key.organisation>-<key.name>-<cloudletKey.name>-<cloudletKey.organisation>).",
"type": "string"
},
"updated_at": {
"type": "string"
}
},
"type": "object"
},
"handler.AppInstKey": {
"properties": {
"cloudlet_key": {
"$ref": "#/definitions/handler.CloudletKey"
},
"name": {
"type": "string"
},
"organization": {
"type": "string"
}
},
"type": "object"
},
"handler.AppInstState": {
"enum": [
"Ready"
],
"type": "string",
"x-enum-varnames": [
"AppInstStateReady"
]
},
"handler.AppKey": {
"description": "is a unique identifier.",
"properties": {
"name": {
"type": "string"
},
"organization": {
"type": "string"
},
"version": {
"type": "string"
}
},
"type": "object"
},
"handler.CloudletKey": {
"properties": {
"name": {
"type": "string"
},
"organization": {
"type": "string"
}
},
"type": "object"
},
"handler.CloudletLoc": {
"properties": {
"latitude": {
"type": "number"
},
"longitude": {
"type": "number"
}
},
"type": "object"
},
"handler.DeploymentType": {
"enum": [
"kubernetes"
],
"type": "string",
"x-enum-varnames": [
"DeploymentTypeKubernetes"
]
},
"handler.Flavor": {
"description": "is a default configuration is applied to app if no configuration\nis provided (e.g. in serverless config). Configuration can be checked at /auth/ctrl/CreateApp",
"properties": {
"name": {
"$ref": "#/definitions/handler.FlavorName"
}
},
"type": "object"
},
"handler.FlavorName": {
"enum": [
"EU.small",
"EU.medium",
"EU.big",
"EU.large"
],
"type": "string",
"x-enum-varnames": [
"DefaultFlavorNameEUSmall",
"DefaultFlavorNameEUMedium",
"DefaultFlavorNameEUBig",
"DefaultFlavorNameEULarge"
]
},
"handler.ImageType": {
"enum": [
"docker"
],
"type": "string",
"x-enum-varnames": [
"ImageTypeDocker"
]
},
"handler.Region": {
"enum": [
"EU"
],
"type": "string",
"x-enum-varnames": [
"RegionEU"
]
},
"handler.RequestCreateApp": {
"description": "necessary App details to create an entity.",
"properties": {
"app": {
"properties": {
"access_ports": {
"description": "Application port to be exposed with ingress in format <protocol:port>.\nNecessary only when manifest is generated automatically. Otherwise,\nall the ports has to be set up manually in YAML manifest.",
"example": "tcp:80",
"type": "string"
},
"allow_serverless": {
"type": "boolean"
},
"defaultFlavor": {
"allOf": [
{
"$ref": "#/definitions/handler.Flavor"
}
],
"description": "Default resource config to be used."
},
"deployment": {
"allOf": [
{
"$ref": "#/definitions/handler.DeploymentType"
}
],
"example": "kubernetes"
},
"deployment_generator": {
"description": "Technical field. Required for providing custom manifest",
"type": "string"
},
"deployment_manifest": {
"description": "Kubernetes manifest. ACCEPTS ONLY DEPLOYMENTS AND SERVICES.",
"type": "string"
},
"image_path": {
"description": "Docker registry URL (e.g. docker.io/library/nginx:latest)",
"type": "string"
},
"image_type": {
"$ref": "#/definitions/handler.ImageType"
},
"key": {
"$ref": "#/definitions/handler.AppKey"
},
"serverless_config": {
"$ref": "#/definitions/handler.ServerlessConfig"
}
},
"type": "object"
},
"region": {
"allOf": [
{
"$ref": "#/definitions/handler.Region"
}
],
"description": "Region to create instance at.",
"example": "EU"
}
},
"type": "object",
"example": {
"appWithoutManifest": {
"region": "EU",
"app": {
"key": {
"organization": "DeveloperOrg",
"name": "test-app-without-manifest",
"version": "1.0"
},
"access_ports": "tcp:80",
"serverless_config": {},
"deployment": "kubernetes",
"image_type": "Docker",
"image_path": "docker.io/library/nginx:latest",
"allow_serverless": true,
"defaultFlavor": {
"name": "EU.small"
}
}
},
"appWitManifest": {
"region": "EU",
"app": {
"key": {
"organization": "DeveloperOrg",
"name": "test-app-without-manifest",
"version": "1.0"
},
"serverless_config": {},
"deployment": "kubernetes",
"image_type": "Docker",
"image_path": "docker.io/library/nginx:latest",
"allow_serverless": true,
"defaultFlavor": {
"name": "EU.small"
},
"deployment_manifest": "apiVersion: v1\nkind: Service\nmetadata:\n name: example-app-tcp\n labels:\n run: example-app\nspec:\n type: LoadBalancer\n ports:\n - name: tcp8080\n protocol: TCP\n port: 80\n targetPort: 80\n selector:\n run: example-app\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: example-app-deployment\nspec:\n replicas: 1\n selector:\n matchLabels:\n run: example-app\n template:\n metadata:\n labels:\n run: example-app\n mexDeployGen: kubernetes-basic\n spec:\n volumes:\n imagePullSecrets:\n - name: mtr.devops.telekom.de\n containers:\n - name: example-app\n image: docker.io/library/nginx:latest\n imagePullPolicy: Always\n ports:\n - containerPort: 80\n protocol: TCP\n",
"deployment_generator": "kubernetes-basic"
}
}
}
},
"handler.RequestCreateAppInst": {
"properties": {
"appinst": {
"properties": {
"app_key": {
"$ref": "#/definitions/handler.AppKey"
},
"key": {
"$ref": "#/definitions/handler.AppInstKey"
}
},
"type": "object"
},
"region": {
"allOf": [
{
"$ref": "#/definitions/handler.Region"
}
],
"description": "Region to create instance at.",
"example": "EU"
}
},
"type": "object"
},
"handler.RequestCreateAppInstMessage": {
"properties": {
"message": {
"type": "string"
}
},
"type": "object"
},
"handler.RequestDeleteApp": {
"properties": {
"key": {
"$ref": "#/definitions/handler.AppKey"
},
"region": {
"allOf": [
{
"$ref": "#/definitions/handler.Region"
}
],
"example": "EU"
}
},
"type": "object"
},
"handler.RequestDeleteAppInst": {
"properties": {
"key": {
"$ref": "#/definitions/handler.AppInstKey"
}
},
"type": "object"
},
"handler.RequestShowApp": {
"properties": {
"access_ports": {
"description": "Application port to be exposed with ingress. Necessary only when\nmanifest is generated automatically. Otherwise, all the ports has to be\nset up manually in YAML manifest",
"example": "tcp:80",
"type": "string"
},
"defaultFlavor": {
"allOf": [
{
"$ref": "#/definitions/handler.Flavor"
}
],
"description": "Default resource config to be used."
},
"deployment": {
"$ref": "#/definitions/handler.DeploymentType"
},
"image_path": {
"description": "Docker registry URL (e.g. docker.io/library/nginx:latest)",
"type": "string"
},
"image_type": {
"$ref": "#/definitions/handler.ImageType"
},
"key": {
"$ref": "#/definitions/handler.AppKey"
},
"region": {
"allOf": [
{
"$ref": "#/definitions/handler.Region"
}
],
"example": "EU"
}
},
"type": "object"
},
"handler.RequestShowAppInst": {
"properties": {
"app_key": {
"$ref": "#/definitions/handler.AppKey"
},
"key": {
"$ref": "#/definitions/handler.AppInstKey"
},
"region": {
"allOf": [
{
"$ref": "#/definitions/handler.Region"
}
],
"description": "Region to create instance at.",
"example": "EU"
},
"state": {
"$ref": "#/definitions/handler.AppInstState"
}
},
"type": "object"
},
"handler.RequestUpdateApp": {
"properties": {
"access_ports": {
"description": "Can be updated only if manifest is generated by EdgeXR.",
"example": "tcp:80",
"type": "string"
},
"defaultFlavor": {
"$ref": "#/definitions/handler.Flavor"
},
"image_path": {
"description": "Docker registry URL (e.g. docker.io/library/nginx:latest)",
"type": "string"
},
"key": {
"allOf": [
{
"$ref": "#/definitions/handler.AppKey"
}
],
"description": "Immutable."
},
"region": {
"allOf": [
{
"$ref": "#/definitions/handler.Region"
}
],
"description": "Immutable.",
"example": "EU"
}
},
"type": "object"
},
"handler.RequestUpdateAppInst": {
"properties": {
"flavor": {
"$ref": "#/definitions/handler.Flavor"
},
"key": {
"$ref": "#/definitions/handler.AppInstKey"
}
},
"type": "object"
},
"handler.ServerlessConfig": {
"description": "is a default configuration is applied to app if no configuration\nis provided (e.g. in serverless config). Configuration can be checked at /auth/ctrl/CreateApp",
"properties": {
"min_replicas": {
"description": "number of replicas (at least 1).",
"type": "integer"
},
"ram": {
"description": "RAM in MB.",
"type": "integer"
},
"vcpus": {
"description": "Virtual CPUs.",
"type": "integer"
}
},
"type": "object"
}
},
"externalDocs": {
"description": "OpenAPI",
"url": "https://swagger.io/resources/open-api/"
},
"info": {
"contact": {},
"description": "# Introduction\n The Master Controller (MC) serves as the central\ngateway for orchestrating edge applications and provides several services to both\napplication developers and operators. For application developers, these APIs allow\nthe management and monitoring of deployments for edge applications. For infrastructure\noperators, these APIs provide ways to manage and monitor the usage of cloudlet\ninfrastructures. Both developers and operators can take advantage of these APIS\nto manage users within the Organization. ## Important note.\n API can return more\nfields than provided in the specification. Specification is a main source of truth.",
"license": {
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
},
"title": "Edge Connect API",
"version": "2.0"
},
"paths": {
"/": {
"get": {
"description": "Returns OK when server is set-up and running.",
"responses": {
"200": {
"description": "OK\" \"ok",
"schema": {
"type": "string"
}
}
},
"summary": "Show server is running",
"tags": [
"monitoring"
]
}
},
"/auth/ctrl/CreateApp": {
"post": {
"consumes": [
"application/json"
],
"description": "Creates App specification, validating of the params. Please, read\ndescription of the fields since not every one is required in every configuration.",
"parameters": [
{
"description": "body",
"in": "body",
"name": "_",
"required": true,
"schema": {
"$ref": "#/definitions/handler.RequestCreateApp"
}
}
],
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
},
"summary": "Creates App specification.",
"tags": [
"App"
]
}
},
"/auth/ctrl/CreateAppInst": {
"post": {
"consumes": [
"application/json"
],
"description": "Create App instance on the cloudlet. Requests can be done as web\nsocket or regular http request returning all the steps as array of json messages.",
"parameters": [
{
"description": "body",
"in": "body",
"name": "_",
"required": true,
"schema": {
"$ref": "#/definitions/handler.RequestCreateAppInst"
}
}
],
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK",
"schema": {
"items": {
"$ref": "#/definitions/handler.RequestCreateAppInstMessage"
},
"type": "array"
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
},
"summary": "Create app instance.",
"tags": [
"AppInst"
]
}
},
"/auth/ctrl/DeleteApp": {
"post": {
"consumes": [
"application/json"
],
"description": "Update app specification with limitation to the key.",
"parameters": [
{
"description": "body",
"in": "body",
"name": "_",
"required": true,
"schema": {
"$ref": "#/definitions/handler.RequestDeleteApp"
}
}
],
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
},
"summary": "Update app specs.",
"tags": [
"App"
]
}
},
"/auth/ctrl/DeleteAppInst": {
"post": {
"consumes": [
"application/json"
],
"description": "Deletes app instance at the specified cloudlet.",
"parameters": [
{
"description": "body",
"in": "body",
"name": "_",
"required": true,
"schema": {
"$ref": "#/definitions/handler.RequestDeleteAppInst"
}
}
],
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
},
"summary": "Deletes app instance by key.",
"tags": [
"AppInst"
]
}
},
"/auth/ctrl/ShowApp": {
"post": {
"consumes": [
"application/json"
],
"description": "returns app specifications for provided region. Filter is done\nwith providing app fields from the model.",
"parameters": [
{
"description": "body",
"in": "body",
"name": "_",
"required": true,
"schema": {
"$ref": "#/definitions/handler.RequestShowApp"
}
}
],
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK",
"schema": {
"items": {
"$ref": "#/definitions/handler.App"
},
"type": "array"
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
},
"summary": "Shows list of apps for the region.",
"tags": [
"App"
]
}
},
"/auth/ctrl/ShowAppInst": {
"post": {
"consumes": [
"application/json"
],
"description": "Returns app instances for provided region. Filter is done with\nproviding app instances fields from the model.",
"parameters": [
{
"description": "body",
"in": "body",
"name": "_",
"required": true,
"schema": {
"$ref": "#/definitions/handler.RequestShowAppInst"
}
}
],
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK",
"schema": {
"items": {
"$ref": "#/definitions/handler.AppInst"
},
"type": "array"
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
},
"summary": "Shows list of app instances for the region.",
"tags": [
"AppInst"
]
}
},
"/auth/ctrl/UpdateApp": {
"post": {
"consumes": [
"application/json"
],
"description": "Update app specification with limitation to the key.",
"parameters": [
{
"description": "body",
"in": "body",
"name": "_",
"required": true,
"schema": {
"$ref": "#/definitions/handler.RequestUpdateApp"
}
}
],
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
},
"summary": "Update app specs.",
"tags": [
"App"
]
}
},
"/auth/ctrl/UpdateAppInst": {
"post": {
"consumes": [
"application/json"
],
"description": "Update app instance by key with limited set of fields.",
"parameters": [
{
"description": "body",
"in": "body",
"name": "_",
"required": true,
"schema": {
"$ref": "#/definitions/handler.RequestUpdateAppInst"
}
}
],
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
},
"summary": "Update app instance by key.",
"tags": [
"AppInst"
]
}
}
},
"securityDefinitions": {
"Bearer": {
"type": "basic"
}
},
"swagger": "2.0"
}

View file

@ -1,308 +0,0 @@
package cmd
import (
"context"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
organization string
appName string
appVersion string
region string
)
func validateBaseURL(baseURL string) error {
url, err := url.Parse(baseURL)
if err != nil {
return fmt.Errorf("decoding error '%s'", err.Error())
}
if url.Scheme == "" {
return fmt.Errorf("schema should be set (add https://)")
}
if len(url.User.Username()) > 0 {
return fmt.Errorf("user and or password should not be set")
}
if url.Path != "" && url.Path != "/" {
return fmt.Errorf("should not contain any path '%s'", url.Path)
}
if len(url.Query()) > 0 {
return fmt.Errorf("should not contain any queries '%s'", url.RawQuery)
}
if len(url.Fragment) > 0 {
return fmt.Errorf("should not contain any fragment '%s'", url.Fragment)
}
return nil
}
func getAPIVersion() string {
version := viper.GetString("api_version")
if version == "" {
version = "v2" // default to v2
}
return strings.ToLower(version)
}
func newSDKClientV1() *edgeconnect.Client {
baseURL := viper.GetString("base_url")
username := viper.GetString("username")
password := viper.GetString("password")
err := validateBaseURL(baseURL)
if err != nil {
fmt.Printf("Error parsing baseURL: '%s' with error: %s\n", baseURL, err.Error())
os.Exit(1)
}
// Build options
opts := []edgeconnect.Option{
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
}
// Add logger only if debug flag is set
if debug {
logger := log.New(os.Stderr, "[DEBUG] ", log.LstdFlags)
opts = append(opts, edgeconnect.WithLogger(logger))
}
if username != "" && password != "" {
return edgeconnect.NewClientWithCredentials(baseURL, username, password, opts...)
}
// Fallback to no auth for now - in production should require auth
return edgeconnect.NewClient(baseURL, opts...)
}
func newSDKClientV2() *v2.Client {
baseURL := viper.GetString("base_url")
username := viper.GetString("username")
password := viper.GetString("password")
err := validateBaseURL(baseURL)
if err != nil {
fmt.Printf("Error parsing baseURL: '%s' with error: %s\n", baseURL, err.Error())
os.Exit(1)
}
// Build options
opts := []v2.Option{
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
}
// Add logger only if debug flag is set
if debug {
logger := log.New(os.Stderr, "[DEBUG] ", log.LstdFlags)
opts = append(opts, v2.WithLogger(logger))
}
if username != "" && password != "" {
return v2.NewClientWithCredentials(baseURL, username, password, opts...)
}
// Fallback to no auth for now - in production should require auth
return v2.NewClient(baseURL, opts...)
}
var appCmd = &cobra.Command{
Use: "app",
Short: "Manage Edge Connect applications",
Long: `Create, show, list, and delete Edge Connect applications.`,
}
var createAppCmd = &cobra.Command{
Use: "create",
Short: "Create a new Edge Connect application",
Run: func(cmd *cobra.Command, args []string) {
apiVersion := getAPIVersion()
var err error
if apiVersion == "v1" {
c := newSDKClientV1()
input := &edgeconnect.NewAppInput{
Region: region,
App: edgeconnect.App{
Key: edgeconnect.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
},
},
}
err = c.CreateApp(context.Background(), input)
} else {
c := newSDKClientV2()
input := &v2.NewAppInput{
Region: region,
App: v2.App{
Key: v2.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
},
},
}
err = c.CreateApp(context.Background(), input)
}
if err != nil {
fmt.Printf("Error creating app: %v\n", err)
os.Exit(1)
}
fmt.Println("Application created successfully")
},
}
var showAppCmd = &cobra.Command{
Use: "show",
Short: "Show details of an Edge Connect application",
Run: func(cmd *cobra.Command, args []string) {
apiVersion := getAPIVersion()
if apiVersion == "v1" {
c := newSDKClientV1()
appKey := edgeconnect.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
}
app, err := c.ShowApp(context.Background(), appKey, region)
if err != nil {
fmt.Printf("Error showing app: %v\n", err)
os.Exit(1)
}
fmt.Printf("Application details:\n%+v\n", app)
} else {
c := newSDKClientV2()
appKey := v2.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
}
app, err := c.ShowApp(context.Background(), appKey, region)
if err != nil {
fmt.Printf("Error showing app: %v\n", err)
os.Exit(1)
}
fmt.Printf("Application details:\n%+v\n", app)
}
},
}
var listAppsCmd = &cobra.Command{
Use: "list",
Short: "List Edge Connect applications",
Run: func(cmd *cobra.Command, args []string) {
apiVersion := getAPIVersion()
if apiVersion == "v1" {
c := newSDKClientV1()
appKey := edgeconnect.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
}
apps, err := c.ShowApps(context.Background(), appKey, region)
if err != nil {
fmt.Printf("Error listing apps: %v\n", err)
os.Exit(1)
}
fmt.Println("Applications:")
for _, app := range apps {
fmt.Printf("%+v\n", app)
}
} else {
c := newSDKClientV2()
appKey := v2.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
}
apps, err := c.ShowApps(context.Background(), appKey, region)
if err != nil {
fmt.Printf("Error listing apps: %v\n", err)
os.Exit(1)
}
fmt.Println("Applications:")
for _, app := range apps {
fmt.Printf("%+v\n", app)
}
}
},
}
var deleteAppCmd = &cobra.Command{
Use: "delete",
Short: "Delete an Edge Connect application",
Run: func(cmd *cobra.Command, args []string) {
apiVersion := getAPIVersion()
var err error
if apiVersion == "v1" {
c := newSDKClientV1()
appKey := edgeconnect.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
}
err = c.DeleteApp(context.Background(), appKey, region)
} else {
c := newSDKClientV2()
appKey := v2.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
}
err = c.DeleteApp(context.Background(), appKey, region)
}
if err != nil {
fmt.Printf("Error deleting app: %v\n", err)
os.Exit(1)
}
fmt.Println("Application deleted successfully")
},
}
func init() {
rootCmd.AddCommand(appCmd)
appCmd.AddCommand(createAppCmd, showAppCmd, listAppsCmd, deleteAppCmd)
// Add common flags to all app commands
appCmds := []*cobra.Command{createAppCmd, showAppCmd, listAppsCmd, deleteAppCmd}
for _, cmd := range appCmds {
cmd.Flags().StringVarP(&organization, "org", "o", "", "organization name (required)")
cmd.Flags().StringVarP(&appName, "name", "n", "", "application name")
cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)")
if err := cmd.MarkFlagRequired("org"); err != nil {
panic(err)
}
if err := cmd.MarkFlagRequired("region"); err != nil {
panic(err)
}
}
// Add required name flag for specific commands
for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} {
if err := cmd.MarkFlagRequired("name"); err != nil {
panic(err)
}
}
}

56
cmd/cli/main.go Normal file
View file

@ -0,0 +1,56 @@
package main
import (
"log"
"os"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driving/cli"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/app"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/cloudlet"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/instance"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/organization"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client"
)
func main() {
// Hexagonal Architecture Wiring
// 1. Infrastructure Layer: Create the low-level EdgeConnect client
baseURL := getEnvOrDefault("EDGE_CONNECT_BASE_URL", "https://console.mobiledgex.net")
username := os.Getenv("EDGE_CONNECT_USERNAME")
password := os.Getenv("EDGE_CONNECT_PASSWORD")
// Use a logger for the infrastructure client
logger := log.New(os.Stderr, "[edgeconnect-client] ", log.LstdFlags)
clientOpts := []edgeconnect_client.Option{
edgeconnect_client.WithLogger(logger),
}
var infraClient *edgeconnect_client.Client
if username != "" && password != "" {
infraClient = edgeconnect_client.NewClientWithCredentials(baseURL, username, password, clientOpts...)
} else {
infraClient = edgeconnect_client.NewClient(baseURL, clientOpts...)
}
// 2. Adapter Layer: Create the driven adapter, injecting the infrastructure client.
// This adapter implements the repository interfaces required by the application layer.
edgeConnectAdapter := edgeconnect.NewAdapter(infraClient)
// 3. Application Layer: Create services, injecting the adapter (which fulfills the repository port).
appService := app.NewService(edgeConnectAdapter)
instanceService := instance.NewService(edgeConnectAdapter)
cloudletService := cloudlet.NewService(edgeConnectAdapter)
organizationService := organization.NewService(edgeConnectAdapter)
// 4. Driving Adapter (Presentation Layer): Execute the CLI, injecting the application services.
cli.ExecuteWithServices(appService, instanceService, cloudletService, organizationService)
}
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

View file

@ -1,296 +0,0 @@
// ABOUTME: CLI command for deleting EdgeConnect applications from YAML configuration
// ABOUTME: Removes applications and their instances based on configuration file specification
package cmd
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
deletev1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/delete/v1"
deletev2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/delete/v2"
"github.com/spf13/cobra"
)
var (
deleteConfigFile string
deleteDryRun bool
deleteAutoApprove bool
)
var deleteCmd = &cobra.Command{
Use: "delete",
Short: "Delete EdgeConnect applications from configuration files",
Long: `Delete EdgeConnect applications and their instances based on YAML configuration files.
This command reads a configuration file, finds matching resources, and deletes them.
Instances are always deleted before the application.`,
Run: func(cmd *cobra.Command, args []string) {
if deleteConfigFile == "" {
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n")
_ = cmd.Usage()
os.Exit(1)
}
if err := runDelete(deleteConfigFile, deleteDryRun, deleteAutoApprove); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func runDelete(configPath string, isDryRun bool, autoApprove bool) error {
// Step 1: Validate and resolve config file path
absPath, err := filepath.Abs(configPath)
if err != nil {
return fmt.Errorf("failed to resolve config file path: %w", err)
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
return fmt.Errorf("configuration file not found: %s", absPath)
}
fmt.Printf("📄 Loading configuration from: %s\n", absPath)
// Step 2: Parse and validate configuration
parser := config.NewParser()
cfg, _, err := parser.ParseFile(absPath)
if err != nil {
return fmt.Errorf("failed to parse configuration: %w", err)
}
if err := parser.Validate(cfg); err != nil {
return fmt.Errorf("configuration validation failed: %w", err)
}
fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name)
// Step 3: Determine API version and create appropriate client
apiVersion := getAPIVersion()
// Step 4: Execute deletion based on API version
if apiVersion == "v1" {
return runDeleteV1(cfg, isDryRun, autoApprove)
}
return runDeleteV2(cfg, isDryRun, autoApprove)
}
func runDeleteV1(cfg *config.EdgeConnectConfig, isDryRun bool, autoApprove bool) error {
// Create v1 client
client := newSDKClientV1()
// Create deletion planner
planner := deletev1.NewPlanner(client)
// Generate deletion plan
fmt.Println("🔍 Analyzing current state and generating deletion plan...")
planOptions := deletev1.DefaultPlanOptions()
planOptions.DryRun = isDryRun
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
if err != nil {
return fmt.Errorf("failed to generate deletion plan: %w", err)
}
// Display plan summary
fmt.Println("\n📋 Deletion Plan:")
fmt.Println(strings.Repeat("=", 50))
fmt.Println(result.Plan.Summary)
fmt.Println(strings.Repeat("=", 50))
// Display warnings if any
if len(result.Warnings) > 0 {
fmt.Println("\n⚠ Warnings:")
for _, warning := range result.Warnings {
fmt.Printf(" • %s\n", warning)
}
}
// If dry-run, stop here
if isDryRun {
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
return nil
}
// Check if there's anything to delete
if result.Plan.TotalActions == 0 {
fmt.Println("\n✅ No resources found to delete.")
return nil
}
fmt.Printf("\nThis will delete %d resource(s). Estimated time: %v\n",
result.Plan.TotalActions, result.Plan.EstimatedDuration)
if !autoApprove && !confirmDeletion() {
fmt.Println("Deletion cancelled.")
return nil
}
// Execute deletion
fmt.Println("\n🗑 Starting deletion...")
manager := deletev1.NewResourceManager(client, deletev1.WithLogger(log.Default()))
deleteResult, err := manager.ExecuteDeletion(context.Background(), result.Plan)
if err != nil {
return fmt.Errorf("deletion failed: %w", err)
}
// Display results
return displayDeletionResults(deleteResult)
}
func runDeleteV2(cfg *config.EdgeConnectConfig, isDryRun bool, autoApprove bool) error {
// Create v2 client
client := newSDKClientV2()
// Create deletion planner
planner := deletev2.NewPlanner(client)
// Generate deletion plan
fmt.Println("🔍 Analyzing current state and generating deletion plan...")
planOptions := deletev2.DefaultPlanOptions()
planOptions.DryRun = isDryRun
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
if err != nil {
return fmt.Errorf("failed to generate deletion plan: %w", err)
}
// Display plan summary
fmt.Println("\n📋 Deletion Plan:")
fmt.Println(strings.Repeat("=", 50))
fmt.Println(result.Plan.Summary)
fmt.Println(strings.Repeat("=", 50))
// Display warnings if any
if len(result.Warnings) > 0 {
fmt.Println("\n⚠ Warnings:")
for _, warning := range result.Warnings {
fmt.Printf(" • %s\n", warning)
}
}
// If dry-run, stop here
if isDryRun {
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
return nil
}
// Check if there's anything to delete
if result.Plan.TotalActions == 0 {
fmt.Println("\n✅ No resources found to delete.")
return nil
}
fmt.Printf("\nThis will delete %d resource(s). Estimated time: %v\n",
result.Plan.TotalActions, result.Plan.EstimatedDuration)
if !autoApprove && !confirmDeletion() {
fmt.Println("Deletion cancelled.")
return nil
}
// Execute deletion
fmt.Println("\n🗑 Starting deletion...")
manager := deletev2.NewResourceManager(client, deletev2.WithLogger(log.Default()))
deleteResult, err := manager.ExecuteDeletion(context.Background(), result.Plan)
if err != nil {
return fmt.Errorf("deletion failed: %w", err)
}
// Display results
return displayDeletionResults(deleteResult)
}
func displayDeletionResults(result interface{}) error {
// Use type assertion to handle both v1 and v2 result types
switch r := result.(type) {
case *deletev1.DeletionResult:
return displayDeletionResultsV1(r)
case *deletev2.DeletionResult:
return displayDeletionResultsV2(r)
default:
return fmt.Errorf("unknown deletion result type")
}
}
func displayDeletionResultsV1(deleteResult *deletev1.DeletionResult) error {
if deleteResult.Success {
fmt.Printf("\n✅ Deletion completed successfully in %v\n", deleteResult.Duration)
if len(deleteResult.CompletedActions) > 0 {
fmt.Println("\nDeleted resources:")
for _, action := range deleteResult.CompletedActions {
fmt.Printf(" ✅ %s %s\n", action.Type, action.Target)
}
}
} else {
fmt.Printf("\n❌ Deletion failed after %v\n", deleteResult.Duration)
if deleteResult.Error != nil {
fmt.Printf("Error: %v\n", deleteResult.Error)
}
if len(deleteResult.FailedActions) > 0 {
fmt.Println("\nFailed actions:")
for _, action := range deleteResult.FailedActions {
fmt.Printf(" ❌ %s %s: %v\n", action.Type, action.Target, action.Error)
}
}
return fmt.Errorf("deletion failed with %d failed actions", len(deleteResult.FailedActions))
}
return nil
}
func displayDeletionResultsV2(deleteResult *deletev2.DeletionResult) error {
if deleteResult.Success {
fmt.Printf("\n✅ Deletion completed successfully in %v\n", deleteResult.Duration)
if len(deleteResult.CompletedActions) > 0 {
fmt.Println("\nDeleted resources:")
for _, action := range deleteResult.CompletedActions {
fmt.Printf(" ✅ %s %s\n", action.Type, action.Target)
}
}
} else {
fmt.Printf("\n❌ Deletion failed after %v\n", deleteResult.Duration)
if deleteResult.Error != nil {
fmt.Printf("Error: %v\n", deleteResult.Error)
}
if len(deleteResult.FailedActions) > 0 {
fmt.Println("\nFailed actions:")
for _, action := range deleteResult.FailedActions {
fmt.Printf(" ❌ %s %s: %v\n", action.Type, action.Target, action.Error)
}
}
return fmt.Errorf("deletion failed with %d failed actions", len(deleteResult.FailedActions))
}
return nil
}
func confirmDeletion() bool {
fmt.Print("Do you want to proceed with deletion? (yes/no): ")
var response string
_, _ = fmt.Scanln(&response)
switch response {
case "yes", "y", "YES", "Y":
return true
default:
return false
}
}
func init() {
rootCmd.AddCommand(deleteCmd)
deleteCmd.Flags().StringVarP(&deleteConfigFile, "file", "f", "", "configuration file path (required)")
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")
if err := deleteCmd.MarkFlagRequired("file"); err != nil {
panic(err)
}
}

View file

@ -1,266 +0,0 @@
package cmd
import (
"context"
"fmt"
"os"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
"github.com/spf13/cobra"
)
var (
cloudletName string
cloudletOrg string
instanceName string
flavorName string
appId string
)
var appInstanceCmd = &cobra.Command{
Use: "instance",
Short: "Manage Edge Connect application instances",
Long: `Create, show, list, and delete Edge Connect application instances.`,
}
var createInstanceCmd = &cobra.Command{
Use: "create",
Short: "Create a new Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) {
apiVersion := getAPIVersion()
var err error
if apiVersion == "v1" {
c := newSDKClientV1()
input := &edgeconnect.NewAppInstanceInput{
Region: region,
AppInst: edgeconnect.AppInstance{
Key: edgeconnect.AppInstanceKey{
Organization: organization,
Name: instanceName,
CloudletKey: edgeconnect.CloudletKey{
Organization: cloudletOrg,
Name: cloudletName,
},
},
AppKey: edgeconnect.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
},
Flavor: edgeconnect.Flavor{
Name: flavorName,
},
},
}
err = c.CreateAppInstance(context.Background(), input)
} else {
c := newSDKClientV2()
input := &v2.NewAppInstanceInput{
Region: region,
AppInst: v2.AppInstance{
Key: v2.AppInstanceKey{
Organization: organization,
Name: instanceName,
CloudletKey: v2.CloudletKey{
Organization: cloudletOrg,
Name: cloudletName,
},
},
AppKey: v2.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
},
Flavor: v2.Flavor{
Name: flavorName,
},
},
}
err = c.CreateAppInstance(context.Background(), input)
}
if err != nil {
fmt.Printf("Error creating app instance: %v\n", err)
os.Exit(1)
}
fmt.Println("Application instance created successfully")
},
}
var showInstanceCmd = &cobra.Command{
Use: "show",
Short: "Show details of an Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) {
apiVersion := getAPIVersion()
if apiVersion == "v1" {
c := newSDKClientV1()
instanceKey := edgeconnect.AppInstanceKey{
Organization: organization,
Name: instanceName,
CloudletKey: edgeconnect.CloudletKey{
Organization: cloudletOrg,
Name: cloudletName,
},
}
appkey := edgeconnect.AppKey{Name: appId}
instance, err := c.ShowAppInstance(context.Background(), instanceKey, appkey, region)
if err != nil {
fmt.Printf("Error showing app instance: %v\n", err)
os.Exit(1)
}
fmt.Printf("Application instance details:\n%+v\n", instance)
} else {
c := newSDKClientV2()
instanceKey := v2.AppInstanceKey{
Organization: organization,
Name: instanceName,
CloudletKey: v2.CloudletKey{
Organization: cloudletOrg,
Name: cloudletName,
},
}
appkey := v2.AppKey{Name: appId}
instance, err := c.ShowAppInstance(context.Background(), instanceKey, appkey, region)
if err != nil {
fmt.Printf("Error showing app instance: %v\n", err)
os.Exit(1)
}
fmt.Printf("Application instance details:\n%+v\n", instance)
}
},
}
var listInstancesCmd = &cobra.Command{
Use: "list",
Short: "List Edge Connect application instances",
Run: func(cmd *cobra.Command, args []string) {
apiVersion := getAPIVersion()
if apiVersion == "v1" {
c := newSDKClientV1()
instanceKey := edgeconnect.AppInstanceKey{
Organization: organization,
Name: instanceName,
CloudletKey: edgeconnect.CloudletKey{
Organization: cloudletOrg,
Name: cloudletName,
},
}
appKey := edgeconnect.AppKey{Name: appId}
instances, err := c.ShowAppInstances(context.Background(), instanceKey, appKey, region)
if err != nil {
fmt.Printf("Error listing app instances: %v\n", err)
os.Exit(1)
}
fmt.Println("Application instances:")
for _, instance := range instances {
fmt.Printf("%+v\n", instance)
}
} else {
c := newSDKClientV2()
instanceKey := v2.AppInstanceKey{
Organization: organization,
Name: instanceName,
CloudletKey: v2.CloudletKey{
Organization: cloudletOrg,
Name: cloudletName,
},
}
appKey := v2.AppKey{Name: appId}
instances, err := c.ShowAppInstances(context.Background(), instanceKey, appKey, region)
if err != nil {
fmt.Printf("Error listing app instances: %v\n", err)
os.Exit(1)
}
fmt.Println("Application instances:")
for _, instance := range instances {
fmt.Printf("%+v\n", instance)
}
}
},
}
var deleteInstanceCmd = &cobra.Command{
Use: "delete",
Short: "Delete an Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) {
apiVersion := getAPIVersion()
var err error
if apiVersion == "v1" {
c := newSDKClientV1()
instanceKey := edgeconnect.AppInstanceKey{
Organization: organization,
Name: instanceName,
CloudletKey: edgeconnect.CloudletKey{
Organization: cloudletOrg,
Name: cloudletName,
},
}
err = c.DeleteAppInstance(context.Background(), instanceKey, region)
} else {
c := newSDKClientV2()
instanceKey := v2.AppInstanceKey{
Organization: organization,
Name: instanceName,
CloudletKey: v2.CloudletKey{
Organization: cloudletOrg,
Name: cloudletName,
},
}
err = c.DeleteAppInstance(context.Background(), instanceKey, region)
}
if err != nil {
fmt.Printf("Error deleting app instance: %v\n", err)
os.Exit(1)
}
fmt.Println("Application instance deleted successfully")
},
}
func init() {
rootCmd.AddCommand(appInstanceCmd)
appInstanceCmd.AddCommand(createInstanceCmd, showInstanceCmd, listInstancesCmd, deleteInstanceCmd)
// Add flags to all instance commands
instanceCmds := []*cobra.Command{createInstanceCmd, showInstanceCmd, listInstancesCmd, deleteInstanceCmd}
for _, cmd := range instanceCmds {
cmd.Flags().StringVarP(&organization, "org", "o", "", "organization name (required)")
cmd.Flags().StringVarP(&instanceName, "name", "n", "", "instance name (required)")
cmd.Flags().StringVarP(&cloudletName, "cloudlet", "c", "", "cloudlet name (required)")
cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)")
cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)")
cmd.Flags().StringVarP(&appId, "app-id", "i", "", "application id")
if err := cmd.MarkFlagRequired("org"); err != nil {
panic(err)
}
if err := cmd.MarkFlagRequired("name"); err != nil {
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
createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)")
createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)")
if err := createInstanceCmd.MarkFlagRequired("app"); err != nil {
panic(err)
}
if err := createInstanceCmd.MarkFlagRequired("flavor"); err != nil {
panic(err)
}
}

17
devbox.json Normal file
View file

@ -0,0 +1,17 @@
{
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json",
"packages": [
"golangci-lint@latest",
"go@1.25"
],
"shell": {
"init_hook": [
"echo 'Welcome to devbox!' > /dev/null"
],
"scripts": {
"test": [
"echo \"Error: no test specified\" && exit 1"
]
}
}
}

105
devbox.lock Normal file
View file

@ -0,0 +1,105 @@
{
"lockfile_version": "1",
"packages": {
"github:NixOS/nixpkgs/nixpkgs-unstable": {
"last_modified": "2025-10-07T08:41:47Z",
"resolved": "github:NixOS/nixpkgs/bce5fe2bb998488d8e7e7856315f90496723793c?lastModified=1759826507&narHash=sha256-vwXL9H5zDHEQA0oFpww2one0%2FhkwnPAjc47LRph6d0I%3D"
},
"go@1.25": {
"last_modified": "2025-08-08T08:05:48Z",
"resolved": "github:NixOS/nixpkgs/a3f3e3f2c983e957af6b07a1db98bafd1f87b7a1#go_1_25",
"source": "devbox-search",
"version": "1.25rc3",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/3h76v26b24dqxhd1i8gzcg8bwzxzmrhl-go-1.25rc3",
"default": true
}
],
"store_path": "/nix/store/3h76v26b24dqxhd1i8gzcg8bwzxzmrhl-go-1.25rc3"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/1sy3nfyahk3a3pg5x301jx96yxg8sw3y-go-1.25rc3",
"default": true
}
],
"store_path": "/nix/store/1sy3nfyahk3a3pg5x301jx96yxg8sw3y-go-1.25rc3"
},
"x86_64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/vh7db8clgyymv47wsddpw908bbf1dikm-go-1.25rc3",
"default": true
}
],
"store_path": "/nix/store/vh7db8clgyymv47wsddpw908bbf1dikm-go-1.25rc3"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/nbq04hs95dpiwmfnqiky5l4z8azbqj6i-go-1.25rc3",
"default": true
}
],
"store_path": "/nix/store/nbq04hs95dpiwmfnqiky5l4z8azbqj6i-go-1.25rc3"
}
}
},
"golangci-lint@latest": {
"last_modified": "2025-09-18T16:33:27Z",
"resolved": "github:NixOS/nixpkgs/f4b140d5b253f5e2a1ff4e5506edbf8267724bde#golangci-lint",
"source": "devbox-search",
"version": "2.4.0",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/2iiw320mwgw7flh47zbz6l62fakrb3dx-golangci-lint-2.4.0",
"default": true
}
],
"store_path": "/nix/store/2iiw320mwgw7flh47zbz6l62fakrb3dx-golangci-lint-2.4.0"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/hwr3wdhqnlcay07xpgv2wm1mx7k5nkhf-golangci-lint-2.4.0",
"default": true
}
],
"store_path": "/nix/store/hwr3wdhqnlcay07xpgv2wm1mx7k5nkhf-golangci-lint-2.4.0"
},
"x86_64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/skcc363l41rm6hjyrhzlfbk3rrwci2lb-golangci-lint-2.4.0",
"default": true
}
],
"store_path": "/nix/store/skcc363l41rm6hjyrhzlfbk3rrwci2lb-golangci-lint-2.4.0"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/dlz6z4dih7rd6q9dnigvz49npfmv8m52-golangci-lint-2.4.0",
"default": true
}
],
"store_path": "/nix/store/dlz6z4dih7rd6q9dnigvz49npfmv8m52-golangci-lint-2.4.0"
}
}
}
}
}

View file

@ -5,7 +5,7 @@
outputs = inputs: outputs = inputs:
let let
goVersion = 25; # Change this to update the whole stack goVersion = "1_25_1"; # Change this to update the whole stack
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forEachSupportedSystem = f: inputs.nixpkgs.lib.genAttrs supportedSystems (system: f { forEachSupportedSystem = f: inputs.nixpkgs.lib.genAttrs supportedSystems (system: f {
@ -17,7 +17,7 @@
in in
{ {
overlays.default = final: prev: { overlays.default = final: prev: {
go = final."go_1_${toString goVersion}"; go = final."go_${goVersion}";
}; };
devShells = forEachSupportedSystem ({ pkgs }: { devShells = forEachSupportedSystem ({ pkgs }: {

2
go.mod
View file

@ -1,4 +1,4 @@
module edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 module edp.buildth.ing/DevFW-CICD/edge-connect-client
go 1.25.1 go 1.25.1

View file

@ -0,0 +1,76 @@
# Proposal: Refactor to Hexagonal Architecture
This document proposes a refactoring of the `edge-connect-client` project to a Hexagonal Architecture (also known as Ports and Adapters). This will improve the project's maintainability, testability, and flexibility.
## Current Architecture
The current project structure is a mix of concerns. The `cmd` package contains both CLI handling and business logic, the `sdk` package is a client for the EdgeXR API, and the `internal` package contains some business logic and configuration handling. This makes it difficult to test the business logic in isolation and to adapt the application to different use cases.
## Proposed Hexagonal Architecture
The hexagonal architecture separates the application's core business logic from the outside world. The core communicates with the outside world through ports (interfaces), which are implemented by adapters.
Here is the proposed directory structure:
```
.
├── cmd/
│ └── main.go
├── internal/
│ ├── core/
│ │ ├── domain/
│ │ │ ├── app.go
│ │ │ └── instance.go
│ │ ├── ports/
│ │ │ ├── driven/
│ │ │ │ ├── app_repository.go
│ │ │ │ └── instance_repository.go
│ │ │ └── driving/
│ │ │ ├── app_service.go
│ │ │ └── instance_service.go
│ │ └── services/
│ │ ├── app_service.go
│ │ └── instance_service.go
│ └── adapters/
│ ├── cli/
│ │ ├── app.go
│ │ └── instance.go
│ └── edgeconnect/
│ ├── app.go
│ └── instance.go
├── go.mod
└── go.sum
```
### Core
* `internal/core/domain`: Contains the core domain objects (e.g., `App`, `AppInstance`). These are plain Go structs with no external dependencies.
* `internal/core/ports`: Defines the interfaces for communication with the outside world.
* `driving`: Interfaces for the services offered by the application (e.g., `AppService`, `InstanceService`).
* `driven`: Interfaces for the services the application needs (e.g., `AppRepository`, `InstanceRepository`).
* `internal/core/services`: Implements the `driving` port interfaces. This is where the core business logic resides.
### Adapters
* `internal/adapters/driving/cli`: The CLI adapter. It implements the user interface and calls the `driving` ports of the core.
* `internal/adapters/driven/edgeconnect`: The EdgeXR API adapter. It implements the `driven` port interfaces and communicates with the EdgeXR API.
### `cmd`
* `cmd/cli/main.go`: The main entry point of the CLI application. It is responsible for wiring everything together: creating the adapters, injecting them into the core services, and starting the CLI.
## Refactoring Steps
1. **Define domain models:** Create the domain models in `internal/core/domain`.
2. **Define ports:** Define the `driving` and `driven` port interfaces in `internal/core/ports`.
3. **Implement core services:** Implement the core business logic in `internal/core/services`.
4. **Create adapters:**
* Move the existing CLI code from `cmd` to `internal/adapters/driving/cli` and adapt it to call the core services.
* Move the existing `sdk` code to `internal/adapters/driven/edgeconnect` and adapt it to implement the repository interfaces.
5. **Wire everything together:** Update `cmd/cli/main.go` to create the adapters and inject them into the core services.
## Benefits
* **Improved Testability:** The core business logic can be tested in isolation, without the need for the CLI framework or the EdgeXR API.
* **Increased Flexibility:** The application can be easily adapted to different use cases by creating new adapters. For example, we could add a REST API by creating a new adapter.
* **Better Separation of Concerns:** The hexagonal architecture enforces a clear separation between the business logic and the infrastructure, making the code easier to understand and maintain.

View file

@ -0,0 +1,705 @@
package edgeconnect
import (
"context"
"fmt"
"net/http"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
)
// Adapter implements the driven ports for the EdgeConnect API.
// It acts as a bridge between the application's core logic and the
// underlying infrastructure client, translating domain requests into
// infrastructure calls.
type Adapter struct {
client *edgeconnect_client.Client
}
// NewAdapter creates a new EdgeConnect adapter.
// It requires a configured infrastructure client to communicate with the API.
func NewAdapter(client *edgeconnect_client.Client) *Adapter {
return &Adapter{client: client}
}
// Ensure the adapter implements all required repository interfaces.
var _ driven.AppRepository = (*Adapter)(nil)
var _ driven.AppInstanceRepository = (*Adapter)(nil)
var _ driven.CloudletRepository = (*Adapter)(nil)
var _ driven.OrganizationRepository = (*Adapter)(nil)
// OrganizationRepository implementation
// CreateOrganization creates a new organization.
func (a *Adapter) CreateOrganization(ctx context.Context, org *domain.Organization) error {
apiPath := "/api/v1/auth/org/create"
_, err := a.client.Call(ctx, http.MethodPost, apiPath, org, nil)
if err != nil {
// TODO: Improve error handling to return domain-specific errors
return fmt.Errorf("failed to create organization %s: %w", org.Name, err)
}
a.client.Logf("Successfully created organization: %s", org.Name)
return nil
}
// ShowOrganization retrieves a single organization by name.
func (a *Adapter) ShowOrganization(ctx context.Context, name string) (*domain.Organization, error) {
apiPath := "/api/v1/auth/org/show"
reqBody := map[string]string{"name": name}
var orgs []domain.Organization
_, err := a.client.Call(ctx, http.MethodPost, apiPath, reqBody, &orgs)
if err != nil {
// TODO: Improve error handling, check for 404 and return domain.ErrResourceNotFound
return nil, fmt.Errorf("failed to show organization %s: %w", name, err)
}
if len(orgs) == 0 {
return nil, fmt.Errorf("organization '%s' not found", name)
}
if len(orgs) > 1 {
a.client.Logf("warning: ShowOrganization for '%s' returned %d results, expected 1", name, len(orgs))
}
return &orgs[0], nil
}
// UpdateOrganization updates an existing organization.
func (a *Adapter) UpdateOrganization(ctx context.Context, org *domain.Organization) error {
apiPath := "/api/v1/auth/org/update"
_, err := a.client.Call(ctx, http.MethodPost, apiPath, org, nil)
if err != nil {
return fmt.Errorf("failed to update organization %s: %w", org.Name, err)
}
a.client.Logf("Successfully updated organization: %s", org.Name)
return nil
}
// DeleteOrganization deletes an organization by name.
func (a *Adapter) DeleteOrganization(ctx context.Context, name string) error {
apiPath := "/api/v1/auth/org/delete"
reqBody := map[string]string{"name": name}
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, reqBody, nil)
if err != nil {
// A 404 status is acceptable, means it's already deleted.
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
a.client.Logf("Organization %s not found for deletion, considered successful.", name)
return nil
}
return fmt.Errorf("failed to delete organization %s: %w", name, err)
}
// The Call method now handles the response body closure if result is not nil.
// If result is nil, we must close it.
if resp != nil && resp.Body != nil {
defer func() {
_ = resp.Body.Close()
}()
}
a.client.Logf("Successfully deleted organization: %s", name)
return nil
}
// AppRepository implementation
// CreateApp creates a new application.
func (a *Adapter) CreateApp(ctx context.Context, region string, app *domain.App) error {
apiPath := "/api/v1/auth/ctrl/CreateApp"
apiApp := toAPIApp(app)
input := &edgeconnect_client.NewAppInput{
Region: region,
App: *apiApp,
}
_, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil)
if err != nil {
return fmt.Errorf("CreateApp failed: %w", err)
}
a.client.Logf("CreateApp: %s/%s version %s created successfully",
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
return nil
}
// ShowApp retrieves a single application.
func (a *Adapter) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) {
apiPath := "/api/v1/auth/ctrl/ShowApp"
apiAppKey := toAPIAppKey(appKey)
filter := edgeconnect_client.AppFilter{
App: edgeconnect_client.App{Key: *apiAppKey},
Region: region,
}
var apps []edgeconnect_client.App
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
if err != nil {
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, "application not found")
}
return nil, fmt.Errorf("ShowApp failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if err := a.client.ParseStreamingResponse(resp, &apps); err != nil {
return nil, fmt.Errorf("ShowApp failed to parse response: %w", err)
}
if len(apps) == 0 {
return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, "application not found")
}
domainApp := toDomainApp(&apps[0])
return &domainApp, nil
}
// ShowApps retrieves all applications matching the filter.
func (a *Adapter) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) {
apiPath := "/api/v1/auth/ctrl/ShowApp"
apiAppKey := toAPIAppKey(appKey)
filter := edgeconnect_client.AppFilter{
App: edgeconnect_client.App{Key: *apiAppKey},
Region: region,
}
var apiApps []edgeconnect_client.App
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
if err != nil {
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
return []domain.App{}, nil // Return empty slice for not found
}
return nil, fmt.Errorf("ShowApps failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if err := a.client.ParseStreamingResponse(resp, &apiApps); err != nil {
return nil, fmt.Errorf("ShowApps failed to parse response: %w", err)
}
a.client.Logf("ShowApps: found %d apps matching criteria", len(apiApps))
domainApps := make([]domain.App, len(apiApps))
for i := range apiApps {
domainApps[i] = toDomainApp(&apiApps[i])
}
return domainApps, nil
}
// UpdateApp updates an existing application.
func (a *Adapter) UpdateApp(ctx context.Context, region string, app *domain.App) error {
apiPath := "/api/v1/auth/ctrl/UpdateApp"
apiApp := toAPIApp(app)
input := &edgeconnect_client.UpdateAppInput{
Region: region,
App: *apiApp,
}
_, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil)
if err != nil {
return fmt.Errorf("UpdateApp failed: %w", err)
}
a.client.Logf("UpdateApp: %s/%s version %s updated successfully",
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
return nil
}
// DeleteApp deletes an application.
func (a *Adapter) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error {
apiPath := "/api/v1/auth/ctrl/DeleteApp"
apiAppKey := toAPIAppKey(appKey)
filter := edgeconnect_client.AppFilter{
App: edgeconnect_client.App{Key: *apiAppKey},
Region: region,
}
_, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
if err != nil {
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
a.client.Logf("App %v not found for deletion, considered successful.", appKey)
return nil
}
return fmt.Errorf("DeleteApp failed: %w", err)
}
a.client.Logf("DeleteApp: %s/%s version %s deleted successfully",
appKey.Organization, appKey.Name, appKey.Version)
return nil
}
// AppInstanceRepository implementation
// CreateAppInstance creates a new application instance.
func (a *Adapter) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
apiPath := "/api/v1/auth/ctrl/CreateAppInst"
apiAppInst := toAPIAppInstance(appInst)
input := &edgeconnect_client.NewAppInstanceInput{
Region: region,
AppInst: *apiAppInst,
}
_, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil)
if err != nil {
return fmt.Errorf("CreateAppInstance failed: %w", err)
}
a.client.Logf("CreateAppInstance: %s/%s created successfully",
input.AppInst.Key.Organization, input.AppInst.Key.Name)
return nil
}
// ShowAppInstance retrieves a single application instance.
func (a *Adapter) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) {
apiPath := "/api/v1/auth/ctrl/ShowAppInst"
apiAppInstKey := toAPIAppInstanceKey(appInstKey)
filter := edgeconnect_client.AppInstanceFilter{
AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey},
Region: region,
}
var appInstances []edgeconnect_client.AppInstance
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
if err != nil {
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, "app instance not found")
}
return nil, fmt.Errorf("ShowAppInstance failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if err := a.client.ParseStreamingAppInstanceResponse(resp, &appInstances); err != nil {
return nil, fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
}
if len(appInstances) == 0 {
return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, "app instance not found")
}
domainAppInst := toDomainAppInstance(&appInstances[0])
return &domainAppInst, nil
}
// ShowAppInstances retrieves all application instances matching the filter.
func (a *Adapter) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) {
apiPath := "/api/v1/auth/ctrl/ShowAppInst"
apiAppInstKey := toAPIAppInstanceKey(appInstKey)
filter := edgeconnect_client.AppInstanceFilter{
AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey},
Region: region,
}
var appInstances []edgeconnect_client.AppInstance
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
if err != nil {
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
return []domain.AppInstance{}, nil
}
return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if err := a.client.ParseStreamingAppInstanceResponse(resp, &appInstances); err != nil {
return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err)
}
a.client.Logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances))
domainAppInsts := make([]domain.AppInstance, len(appInstances))
for i := range appInstances {
domainAppInsts[i] = toDomainAppInstance(&appInstances[i])
}
return domainAppInsts, nil
}
// UpdateAppInstance updates an existing application instance.
func (a *Adapter) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
apiPath := "/api/v1/auth/ctrl/UpdateAppInst"
apiAppInst := toAPIAppInstance(appInst)
input := &edgeconnect_client.UpdateAppInstanceInput{
Region: region,
AppInst: *apiAppInst,
}
_, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil)
if err != nil {
return fmt.Errorf("UpdateAppInstance failed: %w", err)
}
a.client.Logf("UpdateAppInstance: %s/%s updated successfully",
input.AppInst.Key.Organization, input.AppInst.Key.Name)
return nil
}
// RefreshAppInstance refreshes an application instance.
func (a *Adapter) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
apiPath := "/api/v1/auth/ctrl/RefreshAppInst"
apiAppInstKey := toAPIAppInstanceKey(appInstKey)
filter := edgeconnect_client.AppInstanceFilter{
AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey},
Region: region,
}
_, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
if err != nil {
return fmt.Errorf("RefreshAppInstance failed: %w", err)
}
a.client.Logf("RefreshAppInstance: %s/%s refreshed successfully",
appInstKey.Organization, appInstKey.Name)
return nil
}
// DeleteAppInstance deletes an application instance.
func (a *Adapter) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
apiPath := "/api/v1/auth/ctrl/DeleteAppInst"
apiAppInstKey := toAPIAppInstanceKey(appInstKey)
filter := edgeconnect_client.AppInstanceFilter{
AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey},
Region: region,
}
_, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
if err != nil {
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
a.client.Logf("AppInstance %v not found for deletion, considered successful.", appInstKey)
return nil
}
return fmt.Errorf("DeleteAppInstance failed: %w", err)
}
a.client.Logf("DeleteAppInstance: %s/%s deleted successfully",
appInstKey.Organization, appInstKey.Name)
return nil
}
// CloudletRepository implementation
// CreateCloudlet creates a new cloudlet.
func (a *Adapter) CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error {
apiPath := "/api/v1/auth/ctrl/CreateCloudlet"
apiCloudlet := toAPICloudlet(cloudlet)
input := &edgeconnect_client.NewCloudletInput{
Region: region,
Cloudlet: *apiCloudlet,
}
_, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil)
if err != nil {
return fmt.Errorf("CreateCloudlet failed: %w", err)
}
a.client.Logf("CreateCloudlet: %s/%s created successfully",
input.Cloudlet.Key.Organization, input.Cloudlet.Key.Name)
return nil
}
// ShowCloudlet retrieves a single cloudlet.
func (a *Adapter) ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) {
apiPath := "/api/v1/auth/ctrl/ShowCloudlet"
apiCloudletKey := toAPICloudletKey(cloudletKey)
filter := edgeconnect_client.CloudletFilter{
Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey},
Region: region,
}
var cloudlets []edgeconnect_client.Cloudlet
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
if err != nil {
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, "cloudlet not found")
}
return nil, fmt.Errorf("ShowCloudlet failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if err := a.client.ParseStreamingCloudletResponse(resp, &cloudlets); err != nil {
return nil, fmt.Errorf("ShowCloudlet failed to parse response: %w", err)
}
if len(cloudlets) == 0 {
return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, "cloudlet not found")
}
domainCloudlet := toDomainCloudlet(&cloudlets[0])
return &domainCloudlet, nil
}
// ShowCloudlets retrieves all cloudlets matching the filter.
func (a *Adapter) ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) {
apiPath := "/api/v1/auth/ctrl/ShowCloudlet"
apiCloudletKey := toAPICloudletKey(cloudletKey)
filter := edgeconnect_client.CloudletFilter{
Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey},
Region: region,
}
var cloudlets []edgeconnect_client.Cloudlet
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
if err != nil {
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
return []domain.Cloudlet{}, nil
}
return nil, fmt.Errorf("ShowCloudlets failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if err := a.client.ParseStreamingCloudletResponse(resp, &cloudlets); err != nil {
return nil, fmt.Errorf("ShowCloudlets failed to parse response: %w", err)
}
a.client.Logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets))
domainCloudlets := make([]domain.Cloudlet, len(cloudlets))
for i := range cloudlets {
domainCloudlets[i] = toDomainCloudlet(&cloudlets[i])
}
return domainCloudlets, nil
}
// DeleteCloudlet deletes a cloudlet.
func (a *Adapter) DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error {
apiPath := "/api/v1/auth/ctrl/DeleteCloudlet"
apiCloudletKey := toAPICloudletKey(cloudletKey)
filter := edgeconnect_client.CloudletFilter{
Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey},
Region: region,
}
_, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
if err != nil {
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
a.client.Logf("Cloudlet %v not found for deletion, considered successful.", cloudletKey)
return nil
}
return fmt.Errorf("DeleteCloudlet failed: %w", err)
}
a.client.Logf("DeleteCloudlet: %s/%s deleted successfully",
cloudletKey.Organization, cloudletKey.Name)
return nil
}
// GetCloudletManifest retrieves the deployment manifest for a cloudlet.
func (a *Adapter) GetCloudletManifest(ctx context.Context, cloudletKey domain.CloudletKey, region string) (*edgeconnect_client.CloudletManifest, error) {
apiPath := "/api/v1/auth/ctrl/GetCloudletManifest"
apiCloudletKey := toAPICloudletKey(cloudletKey)
filter := edgeconnect_client.CloudletFilter{
Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey},
Region: region,
}
var manifest edgeconnect_client.CloudletManifest
_, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, &manifest)
if err != nil {
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "GetCloudletManifest", cloudletKey, region, "cloudlet manifest not found")
}
return nil, fmt.Errorf("GetCloudletManifest failed: %w", err)
}
a.client.Logf("GetCloudletManifest: retrieved manifest for %s/%s",
cloudletKey.Organization, cloudletKey.Name)
return &manifest, nil
}
// GetCloudletResourceUsage retrieves resource usage for a cloudlet.
func (a *Adapter) GetCloudletResourceUsage(ctx context.Context, cloudletKey domain.CloudletKey, region string) (*edgeconnect_client.CloudletResourceUsage, error) {
apiPath := "/api/v1/auth/ctrl/GetCloudletResourceUsage"
apiCloudletKey := toAPICloudletKey(cloudletKey)
filter := edgeconnect_client.CloudletFilter{
Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey},
Region: region,
}
var usage edgeconnect_client.CloudletResourceUsage
_, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, &usage)
if err != nil {
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "GetCloudletResourceUsage", cloudletKey, region, "cloudlet resource usage not found")
}
return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err)
}
a.client.Logf("GetCloudletResourceUsage: retrieved usage for %s/%s",
cloudletKey.Organization, cloudletKey.Name)
return &usage, nil
}
// Data mapping functions (domain <-> API)
func toAPIApp(app *domain.App) *edgeconnect_client.App {
return &edgeconnect_client.App{
Key: *toAPIAppKey(app.Key),
Deployment: app.Deployment,
ImageType: app.ImageType,
ImagePath: app.ImagePath,
AllowServerless: app.AllowServerless,
DefaultFlavor: toAPIFlavor(app.DefaultFlavor),
ServerlessConfig: app.ServerlessConfig,
DeploymentGenerator: app.DeploymentGenerator,
DeploymentManifest: app.DeploymentManifest,
RequiredOutboundConnections: toAPISecurityRules(app.RequiredOutboundConnections),
Fields: app.Fields,
}
}
func toDomainApp(app *edgeconnect_client.App) domain.App {
return domain.App{
Key: toDomainAppKey(app.Key),
Deployment: app.Deployment,
ImageType: app.ImageType,
ImagePath: app.ImagePath,
AllowServerless: app.AllowServerless,
DefaultFlavor: toDomainFlavor(app.DefaultFlavor),
ServerlessConfig: app.ServerlessConfig,
DeploymentGenerator: app.DeploymentGenerator,
DeploymentManifest: app.DeploymentManifest,
RequiredOutboundConnections: toDomainSecurityRules(app.RequiredOutboundConnections),
Fields: app.Fields,
}
}
func toAPIAppKey(appKey domain.AppKey) *edgeconnect_client.AppKey {
return &edgeconnect_client.AppKey{
Organization: appKey.Organization,
Name: appKey.Name,
Version: appKey.Version,
}
}
func toDomainAppKey(appKey edgeconnect_client.AppKey) domain.AppKey {
return domain.AppKey{
Organization: appKey.Organization,
Name: appKey.Name,
Version: appKey.Version,
}
}
func toAPIFlavor(flavor domain.Flavor) edgeconnect_client.Flavor {
return edgeconnect_client.Flavor{Name: flavor.Name}
}
func toDomainFlavor(flavor edgeconnect_client.Flavor) domain.Flavor {
return domain.Flavor{Name: flavor.Name}
}
func toAPISecurityRules(rules []domain.SecurityRule) []edgeconnect_client.SecurityRule {
apiRules := make([]edgeconnect_client.SecurityRule, len(rules))
for i, r := range rules {
apiRules[i] = edgeconnect_client.SecurityRule{
PortRangeMax: r.PortRangeMax,
PortRangeMin: r.PortRangeMin,
Protocol: r.Protocol,
RemoteCIDR: r.RemoteCIDR,
}
}
return apiRules
}
func toDomainSecurityRules(rules []edgeconnect_client.SecurityRule) []domain.SecurityRule {
domainRules := make([]domain.SecurityRule, len(rules))
for i, r := range rules {
domainRules[i] = domain.SecurityRule{
PortRangeMax: r.PortRangeMax,
PortRangeMin: r.PortRangeMin,
Protocol: r.Protocol,
RemoteCIDR: r.RemoteCIDR,
}
}
return domainRules
}
func toAPIAppInstance(appInst *domain.AppInstance) *edgeconnect_client.AppInstance {
return &edgeconnect_client.AppInstance{
Key: *toAPIAppInstanceKey(appInst.Key),
AppKey: *toAPIAppKey(appInst.AppKey),
Flavor: toAPIFlavor(appInst.Flavor),
State: appInst.State,
PowerState: appInst.PowerState,
Fields: appInst.Fields,
}
}
func toDomainAppInstance(appInst *edgeconnect_client.AppInstance) domain.AppInstance {
return domain.AppInstance{
Key: toDomainAppInstanceKey(appInst.Key),
AppKey: toDomainAppKey(appInst.AppKey),
Flavor: toDomainFlavor(appInst.Flavor),
State: appInst.State,
PowerState: appInst.PowerState,
Fields: appInst.Fields,
}
}
func toAPIAppInstanceKey(key domain.AppInstanceKey) *edgeconnect_client.AppInstanceKey {
return &edgeconnect_client.AppInstanceKey{
Organization: key.Organization,
Name: key.Name,
CloudletKey: toAPICloudletKey(key.CloudletKey),
}
}
func toDomainAppInstanceKey(key edgeconnect_client.AppInstanceKey) domain.AppInstanceKey {
return domain.AppInstanceKey{
Organization: key.Organization,
Name: key.Name,
CloudletKey: toDomainCloudletKey(key.CloudletKey),
}
}
func toAPICloudletKey(key domain.CloudletKey) edgeconnect_client.CloudletKey {
return edgeconnect_client.CloudletKey{
Organization: key.Organization,
Name: key.Name,
}
}
func toDomainCloudletKey(key edgeconnect_client.CloudletKey) domain.CloudletKey {
return domain.CloudletKey{
Organization: key.Organization,
Name: key.Name,
}
}
func toAPICloudlet(cloudlet *domain.Cloudlet) *edgeconnect_client.Cloudlet {
return &edgeconnect_client.Cloudlet{
Key: toAPICloudletKey(cloudlet.Key),
Location: toAPILocation(cloudlet.Location),
IpSupport: cloudlet.IpSupport,
NumDynamicIps: cloudlet.NumDynamicIps,
State: cloudlet.State,
Flavor: toAPIFlavor(cloudlet.Flavor),
PhysicalName: cloudlet.PhysicalName,
Region: cloudlet.Region,
NotifySrvAddr: cloudlet.NotifySrvAddr,
}
}
func toDomainCloudlet(cloudlet *edgeconnect_client.Cloudlet) domain.Cloudlet {
return domain.Cloudlet{
Key: toDomainCloudletKey(cloudlet.Key),
Location: toDomainLocation(cloudlet.Location),
IpSupport: cloudlet.IpSupport,
NumDynamicIps: cloudlet.NumDynamicIps,
State: cloudlet.State,
Flavor: toDomainFlavor(cloudlet.Flavor),
PhysicalName: cloudlet.PhysicalName,
Region: cloudlet.Region,
NotifySrvAddr: cloudlet.NotifySrvAddr,
}
}
func toAPILocation(location domain.Location) edgeconnect_client.Location {
return edgeconnect_client.Location{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
}
func toDomainLocation(location edgeconnect_client.Location) domain.Location {
return domain.Location{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
}

View file

@ -0,0 +1,173 @@
package cli
import (
"context"
"fmt"
"net/url"
"os"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"github.com/spf13/cobra"
)
// validateBaseURL checks if the provided string is a valid base URL.
// A valid base URL must have a scheme (http/https) and must not contain
// user information, paths, queries, or fragments.
func validateBaseURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}
if u.Scheme == "" {
return fmt.Errorf("URL must have a scheme (e.g., http, https)")
}
if u.User != nil {
return fmt.Errorf("URL should not contain user information")
}
if u.Path != "" && u.Path != "/" {
return fmt.Errorf("URL should not contain a path")
}
if u.RawQuery != "" {
return fmt.Errorf("URL should not contain a query string")
}
if u.Fragment != "" {
return fmt.Errorf("URL should not contain a fragment")
}
return nil
}
var (
organization string
appName string
appVersion string
region string
)
var appCmd = &cobra.Command{
Use: "app",
Short: "Manage Edge Connect applications",
Long: `Create, show, list, and delete Edge Connect applications.`,
}
var createAppCmd = &cobra.Command{
Use: "create",
Short: "Create a new Edge Connect application",
Run: func(cmd *cobra.Command, args []string) {
app := &domain.App{
Key: domain.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
},
}
err := services.AppService.CreateApp(context.Background(), region, app)
if err != nil {
fmt.Printf("Error creating app: %v\n", err)
os.Exit(1)
}
fmt.Println("Application created successfully")
},
}
var showAppCmd = &cobra.Command{
Use: "show",
Short: "Show details of an Edge Connect application",
Run: func(cmd *cobra.Command, args []string) {
appKey := domain.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
}
app, err := services.AppService.ShowApp(context.Background(), region, appKey)
if err != nil {
// Handle domain-specific errors with appropriate user feedback
if domain.IsNotFoundError(err) {
fmt.Printf("Application %s/%s (version %s) not found in region %s\n",
appKey.Organization, appKey.Name, appKey.Version, region)
os.Exit(1)
}
if domain.IsValidationError(err) {
fmt.Printf("Validation error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Error showing app: %v\n", err)
os.Exit(1)
}
fmt.Printf("Application details:\n%+v\n", app)
},
}
var listAppsCmd = &cobra.Command{
Use: "list",
Short: "List Edge Connect applications",
Run: func(cmd *cobra.Command, args []string) {
appKey := domain.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
}
apps, err := services.AppService.ShowApps(context.Background(), region, appKey)
if err != nil {
fmt.Printf("Error listing apps: %v\n", err)
os.Exit(1)
}
fmt.Println("Applications:")
for _, app := range apps {
fmt.Printf("%+v\n", app)
}
},
}
var deleteAppCmd = &cobra.Command{
Use: "delete",
Short: "Delete an Edge Connect application",
Run: func(cmd *cobra.Command, args []string) {
appKey := domain.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
}
err := services.AppService.DeleteApp(context.Background(), region, appKey)
if err != nil {
fmt.Printf("Error deleting app: %v\n", err)
os.Exit(1)
}
fmt.Println("Application deleted successfully")
},
}
func init() {
rootCmd.AddCommand(appCmd)
appCmd.AddCommand(createAppCmd, showAppCmd, listAppsCmd, deleteAppCmd)
// Add common flags to all app commands
appCmds := []*cobra.Command{createAppCmd, showAppCmd, listAppsCmd, deleteAppCmd}
for _, cmd := range appCmds {
cmd.Flags().StringVarP(&organization, "org", "o", "", "organization name (required)")
cmd.Flags().StringVarP(&appName, "name", "n", "", "application name")
cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)")
if err := cmd.MarkFlagRequired("org"); err != nil {
panic(fmt.Sprintf("Failed to mark 'org' flag as required: %v", err))
}
if err := cmd.MarkFlagRequired("region"); err != nil {
panic(fmt.Sprintf("Failed to mark 'region' flag as required: %v", err))
}
}
// Add required name flag for specific commands
for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} {
if err := cmd.MarkFlagRequired("name"); err != nil {
panic(fmt.Sprintf("Failed to mark 'name' flag as required: %v", err))
}
}
}

View file

@ -1,4 +1,4 @@
package cmd package cli
import ( import (
"testing" "testing"

View file

@ -1,18 +1,21 @@
// ABOUTME: CLI command for declarative deployment of EdgeConnect applications from YAML configuration // ABOUTME: CLI command for declarative deployment of EdgeConnect applications from YAML configuration
// ABOUTME: Integrates config parser, deployment planner, and resource manager for complete deployment workflow // ABOUTME: Integrates config parser, deployment planner, and resource manager for complete deployment workflow
package cmd package cli
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
applyv1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/apply/v1" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect"
applyv2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/apply/v2" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/apply"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -31,7 +34,9 @@ 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() if err := cmd.Usage(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to display usage: %v\n", err)
}
os.Exit(1) os.Exit(1)
} }
@ -68,27 +73,41 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error {
fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name) fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name)
// Step 3: Determine API version and create appropriate client // Step 3: Create EdgeConnect client
apiVersion := getAPIVersion() baseURL := getEnvOrDefault("EDGE_CONNECT_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live")
token := getEnvOrDefault("EDGE_CONNECT_TOKEN", "")
username := getEnvOrDefault("EDGE_CONNECT_USERNAME", "")
password := getEnvOrDefault("EDGE_CONNECT_PASSWORD", "")
// Step 4-6: Execute deployment based on API version var client *edgeconnect_client.Client
if apiVersion == "v1" {
return runApplyV1(cfg, manifestContent, isDryRun, autoApprove) if token != "" {
fmt.Println("🔐 Using Bearer token authentication")
client = edgeconnect_client.NewClient(baseURL,
edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)),
edgeconnect_client.WithLogger(log.Default()),
)
} else if username != "" && password != "" {
fmt.Println("🔐 Using username/password authentication")
client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password,
edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect_client.WithLogger(log.Default()),
)
} else {
log.Fatal("Authentication required: Set either EDGE_CONNECT_TOKEN or both EDGE_CONNECT_USERNAME and EDGE_CONNECT_PASSWORD")
} }
return runApplyV2(cfg, manifestContent, isDryRun, autoApprove)
}
func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error { // Step 4: Create driven adapter
// Create v1 client adapter := edgeconnect.NewAdapter(client)
client := newSDKClientV1()
// Create deployment planner // Step 5: Create deployment planner
planner := applyv1.NewPlanner(client) planner := apply.NewPlanner(adapter, adapter)
// Generate deployment plan // Step 5: Generate deployment plan
fmt.Println("🔍 Analyzing current state and generating deployment plan...") fmt.Println("🔍 Analyzing current state and generating deployment plan...")
planOptions := applyv1.DefaultPlanOptions() planOptions := apply.DefaultPlanOptions()
planOptions.DryRun = isDryRun planOptions.DryRun = isDryRun
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions) result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
@ -96,7 +115,7 @@ func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun
return fmt.Errorf("failed to generate deployment plan: %w", err) return fmt.Errorf("failed to generate deployment plan: %w", err)
} }
// Display plan summary // Step 6: Display plan summary
fmt.Println("\n📋 Deployment Plan:") fmt.Println("\n📋 Deployment Plan:")
fmt.Println(strings.Repeat("=", 50)) fmt.Println(strings.Repeat("=", 50))
fmt.Println(result.Plan.Summary) fmt.Println(result.Plan.Summary)
@ -110,13 +129,13 @@ func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun
} }
} }
// If dry-run, stop here // Step 7: If dry-run, stop here
if isDryRun { if isDryRun {
fmt.Println("\n🔍 Dry-run complete. No changes were made.") fmt.Println("\n🔍 Dry-run complete. No changes were made.")
return nil return nil
} }
// Confirm deployment // Step 8: Confirm deployment (in non-dry-run mode)
if result.Plan.TotalActions == 0 { if result.Plan.TotalActions == 0 {
fmt.Println("\n✅ No changes needed. Resources are already in desired state.") fmt.Println("\n✅ No changes needed. Resources are already in desired state.")
return nil return nil
@ -130,98 +149,16 @@ func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun
return nil return nil
} }
// Execute deployment // Step 9: Execute deployment
fmt.Println("\n🚀 Starting deployment...") fmt.Println("\n🚀 Starting deployment...")
manager := applyv1.NewResourceManager(client, applyv1.WithLogger(log.Default())) manager := apply.NewResourceManager(adapter, adapter, apply.WithLogger(log.Default()))
deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent) deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
if err != nil { if err != nil {
return fmt.Errorf("deployment failed: %w", err) return fmt.Errorf("deployment failed: %w", err)
} }
// Display results // Step 10: Display results
return displayDeploymentResults(deployResult)
}
func runApplyV2(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error {
// Create v2 client
client := newSDKClientV2()
// Create deployment planner
planner := applyv2.NewPlanner(client)
// Generate deployment plan
fmt.Println("🔍 Analyzing current state and generating deployment plan...")
planOptions := applyv2.DefaultPlanOptions()
planOptions.DryRun = isDryRun
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
if err != nil {
return fmt.Errorf("failed to generate deployment plan: %w", err)
}
// Display plan summary
fmt.Println("\n📋 Deployment Plan:")
fmt.Println(strings.Repeat("=", 50))
fmt.Println(result.Plan.Summary)
fmt.Println(strings.Repeat("=", 50))
// Display warnings if any
if len(result.Warnings) > 0 {
fmt.Println("\n⚠ Warnings:")
for _, warning := range result.Warnings {
fmt.Printf(" • %s\n", warning)
}
}
// If dry-run, stop here
if isDryRun {
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
return nil
}
// Confirm deployment
if result.Plan.TotalActions == 0 {
fmt.Println("\n✅ No changes needed. Resources are already in desired state.")
return nil
}
fmt.Printf("\nThis will perform %d actions. Estimated time: %v\n",
result.Plan.TotalActions, result.Plan.EstimatedDuration)
if !autoApprove && !confirmDeployment() {
fmt.Println("Deployment cancelled.")
return nil
}
// Execute deployment
fmt.Println("\n🚀 Starting deployment...")
manager := applyv2.NewResourceManager(client, applyv2.WithLogger(log.Default()))
deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
if err != nil {
return fmt.Errorf("deployment failed: %w", err)
}
// Display results
return displayDeploymentResults(deployResult)
}
func displayDeploymentResults(result interface{}) error {
// 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
switch r := result.(type) {
case *applyv1.ExecutionResult:
return displayDeploymentResultsV1(r)
case *applyv2.ExecutionResult:
return displayDeploymentResultsV2(r)
default:
return fmt.Errorf("unknown deployment result type")
}
}
func displayDeploymentResultsV1(deployResult *applyv1.ExecutionResult) error {
if deployResult.Success { if deployResult.Success {
fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration) fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration)
if len(deployResult.CompletedActions) > 0 { if len(deployResult.CompletedActions) > 0 {
@ -243,38 +180,17 @@ func displayDeploymentResultsV1(deployResult *applyv1.ExecutionResult) error {
} }
return fmt.Errorf("deployment failed with %d failed actions", len(deployResult.FailedActions)) return fmt.Errorf("deployment failed with %d failed actions", len(deployResult.FailedActions))
} }
return nil
}
func displayDeploymentResultsV2(deployResult *applyv2.ExecutionResult) error {
if deployResult.Success {
fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration)
if len(deployResult.CompletedActions) > 0 {
fmt.Println("\nCompleted actions:")
for _, action := range deployResult.CompletedActions {
fmt.Printf(" ✅ %s %s\n", action.Type, action.Target)
}
}
} else {
fmt.Printf("\n❌ Deployment failed after %v\n", deployResult.Duration)
if deployResult.Error != nil {
fmt.Printf("Error: %v\n", deployResult.Error)
}
if len(deployResult.FailedActions) > 0 {
fmt.Println("\nFailed actions:")
for _, action := range deployResult.FailedActions {
fmt.Printf(" ❌ %s %s: %v\n", action.Type, action.Target, action.Error)
}
}
return fmt.Errorf("deployment failed with %d failed actions", len(deployResult.FailedActions))
}
return nil return nil
} }
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) if _, err := fmt.Scanln(&response); err != nil {
fmt.Fprintf(os.Stderr, "Failed to read input: %v\n", err)
return false
}
switch response { switch response {
case "yes", "y", "YES", "Y": case "yes", "y", "YES", "Y":
@ -284,6 +200,13 @@ func confirmDeployment() bool {
} }
} }
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func init() { func init() {
rootCmd.AddCommand(applyCmd) rootCmd.AddCommand(applyCmd)
@ -292,6 +215,6 @@ func init() {
applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan") applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan")
if err := applyCmd.MarkFlagRequired("file"); err != nil { if err := applyCmd.MarkFlagRequired("file"); err != nil {
panic(err) panic(fmt.Sprintf("Failed to mark 'file' flag as required: %v", err))
} }
} }

View file

@ -0,0 +1,156 @@
package cli
import (
"context"
"fmt"
"os"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"github.com/spf13/cobra"
)
var (
cloudletName string
cloudletOrg string
instanceName string
flavorName string
)
var appInstanceCmd = &cobra.Command{
Use: "instance",
Short: "Manage Edge Connect application instances",
Long: `Create, show, list, and delete Edge Connect application instances.`,
}
var createInstanceCmd = &cobra.Command{
Use: "create",
Short: "Create a new Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) {
appInst := &domain.AppInstance{
Key: domain.AppInstanceKey{
Organization: organization,
Name: instanceName,
CloudletKey: domain.CloudletKey{
Organization: cloudletOrg,
Name: cloudletName,
},
},
AppKey: domain.AppKey{
Organization: organization,
Name: appName,
Version: appVersion,
},
Flavor: domain.Flavor{
Name: flavorName,
},
}
err := services.InstanceService.CreateAppInstance(context.Background(), region, appInst)
if err != nil {
fmt.Printf("Error creating app instance: %v\n", err)
os.Exit(1)
}
fmt.Println("Application instance created successfully")
},
}
var showInstanceCmd = &cobra.Command{
Use: "show",
Short: "Show details of an Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) {
instanceKey := domain.AppInstanceKey{
Organization: organization,
Name: instanceName,
CloudletKey: domain.CloudletKey{
Organization: cloudletOrg,
Name: cloudletName,
},
}
instance, err := services.InstanceService.ShowAppInstance(context.Background(), region, instanceKey)
if err != nil {
fmt.Printf("Error showing app instance: %v\n", err)
os.Exit(1)
}
fmt.Printf("Application instance details:\n%+v\n", instance)
},
}
var listInstancesCmd = &cobra.Command{
Use: "list",
Short: "List Edge Connect application instances",
Run: func(cmd *cobra.Command, args []string) {
instanceKey := domain.AppInstanceKey{
Organization: organization,
Name: instanceName,
CloudletKey: domain.CloudletKey{
Organization: cloudletOrg,
Name: cloudletName,
},
}
instances, err := services.InstanceService.ShowAppInstances(context.Background(), region, instanceKey)
if err != nil {
fmt.Printf("Error listing app instances: %v\n", err)
os.Exit(1)
}
fmt.Println("Application instances:")
for _, instance := range instances {
fmt.Printf("%+v\n", instance)
}
},
}
var deleteInstanceCmd = &cobra.Command{
Use: "delete",
Short: "Delete an Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) {
instanceKey := domain.AppInstanceKey{
Organization: organization,
Name: instanceName,
CloudletKey: domain.CloudletKey{
Organization: cloudletOrg,
Name: cloudletName,
},
}
err := services.InstanceService.DeleteAppInstance(context.Background(), region, instanceKey)
if err != nil {
fmt.Printf("Error deleting app instance: %v\n", err)
os.Exit(1)
}
fmt.Println("Application instance deleted successfully")
},
}
func init() {
rootCmd.AddCommand(appInstanceCmd)
appInstanceCmd.AddCommand(createInstanceCmd, showInstanceCmd, listInstancesCmd, deleteInstanceCmd)
// Add flags to all instance commands
instanceCmds := []*cobra.Command{createInstanceCmd, showInstanceCmd, listInstancesCmd, deleteInstanceCmd}
for _, cmd := range instanceCmds {
cmd.Flags().StringVarP(&organization, "org", "o", "", "organization name (required)")
cmd.Flags().StringVarP(&instanceName, "name", "n", "", "instance name (required)")
cmd.Flags().StringVarP(&cloudletName, "cloudlet", "c", "", "cloudlet name (required)")
cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)")
cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)")
for _, flag := range []string{"org", "name", "cloudlet", "cloudlet-org", "region"} {
if err := cmd.MarkFlagRequired(flag); err != nil {
panic(fmt.Sprintf("Failed to mark '%s' flag as required: %v", flag, err))
}
}
}
// Add additional flags for create command
createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)")
createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)")
if err := createInstanceCmd.MarkFlagRequired("app"); err != nil {
panic(fmt.Sprintf("Failed to mark 'app' flag as required: %v", err))
}
if err := createInstanceCmd.MarkFlagRequired("flavor"); err != nil {
panic(fmt.Sprintf("Failed to mark 'flavor' flag as required: %v", err))
}
}

View file

@ -0,0 +1,112 @@
package cli
import (
"context"
"fmt"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(organizationCmd)
organizationCmd.AddCommand(createOrganizationCmd, showOrganizationCmd, updateOrganizationCmd, deleteOrganizationCmd)
// Flags for create/update
createOrganizationCmd.Flags().StringVar(&orgAddress, "address", "", "Address of the organization")
createOrganizationCmd.Flags().StringVar(&orgPhone, "phone", "", "Phone number of the organization")
updateOrganizationCmd.Flags().StringVar(&orgAddress, "address", "", "New address for the organization")
updateOrganizationCmd.Flags().StringVar(&orgPhone, "phone", "", "New phone number for the organization")
}
var (
orgAddress string
orgPhone string
)
// organizationCmd represents the parent command for all organization-related actions.
var organizationCmd = &cobra.Command{
Use: "organization",
Short: "Manage organizations",
Long: `Create, show, update, and delete organizations in Edge Connect.`,
Aliases: []string{"org"},
}
var createOrganizationCmd = &cobra.Command{
Use: "create [name]",
Short: "Create a new organization",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
org := &domain.Organization{
Name: args[0],
Address: orgAddress,
Phone: orgPhone,
}
err := services.OrganizationService.Create(context.Background(), org)
if err != nil {
return fmt.Errorf("error creating organization: %w", err)
}
fmt.Printf("Organization '%s' created successfully.\n", args[0])
return nil
},
}
var showOrganizationCmd = &cobra.Command{
Use: "show [name]",
Short: "Show details of an organization",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
org, err := services.OrganizationService.Get(context.Background(), args[0])
if err != nil {
return fmt.Errorf("error showing organization: %w", err)
}
fmt.Printf("Organization Details:\n")
fmt.Printf(" Name: %s\n", org.Name)
fmt.Printf(" Address: %s\n", org.Address)
fmt.Printf(" Phone: %s\n", org.Phone)
return nil
},
}
var updateOrganizationCmd = &cobra.Command{
Use: "update [name]",
Short: "Update an organization",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// First, get the current organization to update
org, err := services.OrganizationService.Get(context.Background(), args[0])
if err != nil {
return fmt.Errorf("could not retrieve organization to update: %w", err)
}
// Update fields if flags were provided
if cmd.Flags().Changed("address") {
org.Address = orgAddress
}
if cmd.Flags().Changed("phone") {
org.Phone = orgPhone
}
err = services.OrganizationService.Update(context.Background(), org)
if err != nil {
return fmt.Errorf("error updating organization: %w", err)
}
fmt.Printf("Organization '%s' updated successfully.\n", args[0])
return nil
},
}
var deleteOrganizationCmd = &cobra.Command{
Use: "delete [name]",
Short: "Delete an organization",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
err := services.OrganizationService.Delete(context.Background(), args[0])
if err != nil {
return fmt.Errorf("error deleting organization: %w", err)
}
fmt.Printf("Organization '%s' deleted successfully.\n", args[0])
return nil
},
}

View file

@ -1,22 +1,32 @@
package cmd package cli
import ( import (
"fmt" "fmt"
"os" "os"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
var ( var (
cfgFile string cfgFile string
baseURL string baseURL string
username string username string
password string password string
debug bool
apiVersion string // Services injected via constructor - no global state
services *ServiceContainer
) )
// ServiceContainer holds injected services (simple struct - no complex container)
type ServiceContainer struct {
AppService driving.AppService
InstanceService driving.AppInstanceService
CloudletService driving.CloudletService
OrganizationService driving.OrganizationService
}
// rootCmd represents the base command when called without any subcommands // rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "edge-connect", Use: "edge-connect",
@ -34,27 +44,37 @@ func Execute() {
} }
} }
// ExecuteWithServices executes CLI with dependency-injected services (simple parameter passing)
func ExecuteWithServices(appSvc driving.AppService, instanceSvc driving.AppInstanceService, cloudletSvc driving.CloudletService, orgSvc driving.OrganizationService) {
// Simple dependency injection - just store services in container
services = &ServiceContainer{
AppService: appSvc,
InstanceService: instanceSvc,
CloudletService: cloudletSvc,
OrganizationService: orgSvc,
}
Execute()
}
func init() { func init() {
cobra.OnInitialize(initConfig) cobra.OnInitialize(initConfig)
rootCmd.AddCommand(organizationCmd)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.edge-connect.yaml)") rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.edge-connect.yaml)")
rootCmd.PersistentFlags().StringVar(&baseURL, "base-url", "", "base URL for the Edge Connect API") rootCmd.PersistentFlags().StringVar(&baseURL, "base-url", "", "base URL for the Edge Connect API")
rootCmd.PersistentFlags().StringVar(&username, "username", "", "username for authentication") rootCmd.PersistentFlags().StringVar(&username, "username", "", "username for authentication")
rootCmd.PersistentFlags().StringVar(&password, "password", "", "password for authentication") rootCmd.PersistentFlags().StringVar(&password, "password", "", "password for authentication")
rootCmd.PersistentFlags().StringVar(&apiVersion, "api-version", "v2", "API version to use (v1 or v2)")
rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging")
if err := viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")); err != nil { if err := viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")); err != nil {
panic(err) panic(fmt.Sprintf("Failed to bind base-url flag: %v", err))
} }
if err := viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")); err != nil { if err := viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")); err != nil {
panic(err) panic(fmt.Sprintf("Failed to bind username flag: %v", err))
} }
if err := viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")); err != nil { if err := viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")); err != nil {
panic(err) panic(fmt.Sprintf("Failed to bind password flag: %v", err))
}
if err := viper.BindPFlag("api_version", rootCmd.PersistentFlags().Lookup("api-version")); err != nil {
panic(err)
} }
} }
@ -62,16 +82,13 @@ func initConfig() {
viper.AutomaticEnv() viper.AutomaticEnv()
viper.SetEnvPrefix("EDGE_CONNECT") viper.SetEnvPrefix("EDGE_CONNECT")
if err := viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL"); err != nil { if err := viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL"); err != nil {
panic(err) panic(fmt.Sprintf("Failed to bind base_url environment variable: %v", err))
} }
if err := viper.BindEnv("username", "EDGE_CONNECT_USERNAME"); err != nil { if err := viper.BindEnv("username", "EDGE_CONNECT_USERNAME"); err != nil {
panic(err) panic(fmt.Sprintf("Failed to bind username environment variable: %v", err))
} }
if err := viper.BindEnv("password", "EDGE_CONNECT_PASSWORD"); err != nil { if err := viper.BindEnv("password", "EDGE_CONNECT_PASSWORD"); err != nil {
panic(err) panic(fmt.Sprintf("Failed to bind password environment variable: %v", err))
}
if err := viper.BindEnv("api_version", "EDGE_CONNECT_API_VERSION"); err != nil {
panic(err)
} }
if cfgFile != "" { if cfgFile != "" {
@ -91,4 +108,4 @@ func initConfig() {
if err := viper.ReadInConfig(); err == nil { if err := viper.ReadInConfig(); err == nil {
fmt.Println("Using config file:", viper.ConfigFileUsed()) fmt.Println("Using config file:", viper.ConfigFileUsed())
} }
} }

View file

@ -0,0 +1,157 @@
package app
import (
"context"
"strings"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving"
)
type service struct {
appRepo driven.AppRepository
}
func NewService(appRepo driven.AppRepository) driving.AppService {
return &service{appRepo: appRepo}
}
func (s *service) CreateApp(ctx context.Context, region string, app *domain.App) error {
// Validate inputs before delegating to repository
if err := s.validateApp(app); err != nil {
return err
}
if region == "" {
return domain.ErrMissingRegion
}
if err := s.appRepo.CreateApp(ctx, region, app); err != nil {
// Map repository errors to domain errors with context
if domain.IsNotFoundError(err) {
return domain.NewAppError(domain.ErrResourceConflict, "CreateApp", app.Key, region,
"app may already exist or have conflicting configuration")
}
return domain.NewAppError(domain.ErrInternalError, "CreateApp", app.Key, region,
"failed to create application").WithDetails(err.Error())
}
return nil
}
func (s *service) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) {
if err := s.validateAppKey(appKey); err != nil {
return nil, err
}
if region == "" {
return nil, domain.ErrMissingRegion
}
app, err := s.appRepo.ShowApp(ctx, region, appKey)
if err != nil {
if domain.IsNotFoundError(err) {
return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region,
"application does not exist")
}
return nil, domain.NewAppError(domain.ErrInternalError, "ShowApp", appKey, region,
"failed to retrieve application").WithDetails(err.Error())
}
return app, nil
}
func (s *service) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) {
if region == "" {
return nil, domain.ErrMissingRegion
}
apps, err := s.appRepo.ShowApps(ctx, region, appKey)
if err != nil {
return nil, domain.NewAppError(domain.ErrInternalError, "ShowApps", appKey, region,
"failed to list applications").WithDetails(err.Error())
}
return apps, nil
}
func (s *service) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error {
if err := s.validateAppKey(appKey); err != nil {
return err
}
if region == "" {
return domain.ErrMissingRegion
}
if err := s.appRepo.DeleteApp(ctx, region, appKey); err != nil {
if domain.IsNotFoundError(err) {
return domain.NewAppError(domain.ErrResourceNotFound, "DeleteApp", appKey, region,
"application does not exist")
}
return domain.NewAppError(domain.ErrInternalError, "DeleteApp", appKey, region,
"failed to delete application").WithDetails(err.Error())
}
return nil
}
func (s *service) UpdateApp(ctx context.Context, region string, app *domain.App) error {
if err := s.validateApp(app); err != nil {
return err
}
if region == "" {
return domain.ErrMissingRegion
}
if err := s.appRepo.UpdateApp(ctx, region, app); err != nil {
if domain.IsNotFoundError(err) {
return domain.NewAppError(domain.ErrResourceNotFound, "UpdateApp", app.Key, region,
"application does not exist")
}
return domain.NewAppError(domain.ErrInternalError, "UpdateApp", app.Key, region,
"failed to update application").WithDetails(err.Error())
}
return nil
}
// validateApp performs business logic validation on an app
func (s *service) validateApp(app *domain.App) error {
if app == nil {
return domain.NewDomainError(domain.ErrValidationFailed, "application cannot be nil")
}
if err := s.validateAppKey(app.Key); err != nil {
return err
}
if strings.TrimSpace(app.ImagePath) == "" {
return domain.NewDomainError(domain.ErrValidationFailed, "image path is required")
}
if strings.TrimSpace(app.Deployment) == "" {
return domain.NewDomainError(domain.ErrValidationFailed, "deployment type is required")
}
return nil
}
// validateAppKey performs business logic validation on an app key
func (s *service) validateAppKey(appKey domain.AppKey) error {
if strings.TrimSpace(appKey.Organization) == "" {
return domain.ErrInvalidAppKey.WithDetails("organization is required")
}
if strings.TrimSpace(appKey.Name) == "" {
return domain.ErrInvalidAppKey.WithDetails("name is required")
}
if strings.TrimSpace(appKey.Version) == "" {
return domain.ErrInvalidAppKey.WithDetails("version is required")
}
return nil
}

View file

@ -1,14 +1,15 @@
// ABOUTME: Resource management for EdgeConnect apply command with deployment execution and rollback // ABOUTME: Resource management for EdgeConnect apply command with deployment execution and rollback
// ABOUTME: Handles actual deployment operations, manifest processing, and error recovery with parallel execution // ABOUTME: Handles actual deployment operations, manifest processing, and error recovery with parallel execution
package v1 package apply
import ( import (
"context" "context"
"fmt" "fmt"
"time" "time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
) )
// ResourceManagerInterface defines the interface for resource management // ResourceManagerInterface defines the interface for resource management
@ -25,7 +26,8 @@ type ResourceManagerInterface interface {
// EdgeConnectResourceManager implements resource management for EdgeConnect // EdgeConnectResourceManager implements resource management for EdgeConnect
type EdgeConnectResourceManager struct { type EdgeConnectResourceManager struct {
client EdgeConnectClientInterface appRepo driven.AppRepository
appInstRepo driven.AppInstanceRepository
parallelLimit int parallelLimit int
rollbackOnFail bool rollbackOnFail bool
logger Logger logger Logger
@ -66,14 +68,15 @@ func DefaultResourceManagerOptions() ResourceManagerOptions {
} }
// NewResourceManager creates a new EdgeConnect resource manager // NewResourceManager creates a new EdgeConnect resource manager
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface { func NewResourceManager(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
options := DefaultResourceManagerOptions() options := DefaultResourceManagerOptions()
for _, opt := range opts { for _, opt := range opts {
opt(&options) opt(&options)
} }
return &EdgeConnectResourceManager{ return &EdgeConnectResourceManager{
client: client, appRepo: appRepo,
appInstRepo: appInstRepo,
parallelLimit: options.ParallelLimit, parallelLimit: options.ParallelLimit,
rollbackOnFail: options.RollbackOnFail, rollbackOnFail: options.RollbackOnFail,
logger: options.Logger, logger: options.Logger,
@ -133,7 +136,7 @@ func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan
strategyConfig := rm.strategyConfig strategyConfig := rm.strategyConfig
strategyConfig.ParallelOperations = rm.parallelLimit > 1 strategyConfig.ParallelOperations = rm.parallelLimit > 1
factory := NewStrategyFactory(rm.client, strategyConfig, rm.logger) factory := NewStrategyFactory(rm.appRepo, rm.appInstRepo, strategyConfig, rm.logger)
strategy, err := factory.CreateStrategy(strategyName) strategy, err := factory.CreateStrategy(strategyName)
if err != nil { if err != nil {
result := &ExecutionResult{ result := &ExecutionResult{
@ -190,8 +193,8 @@ func (rm *EdgeConnectResourceManager) ValidatePrerequisites(ctx context.Context,
} }
// Validate that we have required client capabilities // Validate that we have required client capabilities
if rm.client == nil { if rm.appRepo == nil || rm.appInstRepo == nil {
return fmt.Errorf("EdgeConnect client is not configured") return fmt.Errorf("repositories are not configured")
} }
rm.logf("Prerequisites validation passed") rm.logf("Prerequisites validation passed")
@ -250,13 +253,13 @@ func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context,
// rollbackApp deletes an application that was created // rollbackApp deletes an application that was created
func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error { func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
appKey := edgeconnect.AppKey{ appKey := domain.AppKey{
Organization: plan.AppAction.Desired.Organization, Organization: plan.AppAction.Desired.Organization,
Name: plan.AppAction.Desired.Name, Name: plan.AppAction.Desired.Name,
Version: plan.AppAction.Desired.Version, Version: plan.AppAction.Desired.Version,
} }
return rm.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region) return rm.appRepo.DeleteApp(ctx, plan.AppAction.Desired.Region, appKey)
} }
// rollbackInstance deletes an instance that was created // rollbackInstance deletes an instance that was created
@ -264,15 +267,15 @@ func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, acti
// Find the instance action to get the details // Find the instance action to get the details
for _, instanceAction := range plan.InstanceActions { for _, instanceAction := range plan.InstanceActions {
if instanceAction.InstanceName == action.Target { if instanceAction.InstanceName == action.Target {
instanceKey := edgeconnect.AppInstanceKey{ instanceKey := domain.AppInstanceKey{
Organization: plan.AppAction.Desired.Organization, Organization: plan.AppAction.Desired.Organization,
Name: instanceAction.InstanceName, Name: instanceAction.InstanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: domain.CloudletKey{
Organization: instanceAction.Target.CloudletOrg, Organization: instanceAction.Target.CloudletOrg,
Name: instanceAction.Target.CloudletName, Name: instanceAction.Target.CloudletName,
}, },
} }
return rm.client.DeleteAppInstance(ctx, instanceKey, instanceAction.Target.Region) return rm.appInstRepo.DeleteAppInstance(ctx, instanceAction.Target.Region, instanceKey)
} }
} }
return fmt.Errorf("instance action not found for rollback: %s", action.Target) return fmt.Errorf("instance action not found for rollback: %s", action.Target)
@ -283,4 +286,4 @@ func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
if rm.logger != nil { if rm.logger != nil {
rm.logger.Printf("[ResourceManager] "+format, v...) rm.logger.Printf("[ResourceManager] "+format, v...)
} }
} }

View file

@ -1,6 +1,6 @@
// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios // ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios
// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients // ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients
package v1 package apply
import ( import (
"context" "context"
@ -10,8 +10,8 @@ import (
"testing" "testing"
"time" "time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -19,36 +19,61 @@ import (
// MockResourceClient extends MockEdgeConnectClient with resource management methods // MockResourceClient extends MockEdgeConnectClient with resource management methods
type MockResourceClient struct { type MockResourceClient struct {
MockEdgeConnectClient mock.Mock
} }
func (m *MockResourceClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error { func (m *MockResourceClient) CreateApp(ctx context.Context, region string, app *domain.App) error {
args := m.Called(ctx, input) args := m.Called(ctx, region, app)
return args.Error(0) return args.Error(0)
} }
func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error { func (m *MockResourceClient) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) {
args := m.Called(ctx, input) args := m.Called(ctx, region, appKey)
return args.Get(0).(*domain.App), args.Error(1)
}
func (m *MockResourceClient) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) {
args := m.Called(ctx, region, appKey)
return args.Get(0).([]domain.App), args.Error(1)
}
func (m *MockResourceClient) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error {
args := m.Called(ctx, region, appKey)
return args.Error(0) return args.Error(0)
} }
func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error { func (m *MockResourceClient) UpdateApp(ctx context.Context, region string, app *domain.App) error {
args := m.Called(ctx, appKey, region) args := m.Called(ctx, region, app)
return args.Error(0) return args.Error(0)
} }
func (m *MockResourceClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error { func (m *MockResourceClient) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
args := m.Called(ctx, input) args := m.Called(ctx, region, appInst)
return args.Error(0) return args.Error(0)
} }
func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error { func (m *MockResourceClient) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) {
args := m.Called(ctx, input) args := m.Called(ctx, region, appInstKey)
return args.Get(0).(*domain.AppInstance), args.Error(1)
}
func (m *MockResourceClient) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) {
args := m.Called(ctx, region, appInstKey)
return args.Get(0).([]domain.AppInstance), args.Error(1)
}
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
args := m.Called(ctx, region, appInstKey)
return args.Error(0) return args.Error(0)
} }
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error { func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
args := m.Called(ctx, instanceKey, region) args := m.Called(ctx, region, appInst)
return args.Error(0)
}
func (m *MockResourceClient) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
args := m.Called(ctx, region, appInstKey)
return args.Error(0) return args.Error(0)
} }
@ -62,8 +87,9 @@ func (l *TestLogger) Printf(format string, v ...interface{}) {
} }
func TestNewResourceManager(t *testing.T) { func TestNewResourceManager(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
manager := NewResourceManager(mockClient) mockAppInstRepo := &MockResourceClient{}
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
assert.NotNil(t, manager) assert.NotNil(t, manager)
assert.IsType(t, &EdgeConnectResourceManager{}, manager) assert.IsType(t, &EdgeConnectResourceManager{}, manager)
@ -78,10 +104,11 @@ func TestDefaultResourceManagerOptions(t *testing.T) {
} }
func TestWithOptions(t *testing.T) { func TestWithOptions(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
mockAppInstRepo := &MockResourceClient{}
logger := &TestLogger{} logger := &TestLogger{}
manager := NewResourceManager(mockClient, manager := NewResourceManager(mockAppRepo, mockAppInstRepo,
WithParallelLimit(10), WithParallelLimit(10),
WithRollbackOnFail(false), WithRollbackOnFail(false),
WithLogger(logger), WithLogger(logger),
@ -177,17 +204,18 @@ func createTestStrategyConfig() StrategyConfig {
} }
func TestApplyDeploymentSuccess(t *testing.T) { func TestApplyDeploymentSuccess(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
mockAppInstRepo := &MockResourceClient{}
logger := &TestLogger{} logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan() plan := createTestDeploymentPlan()
config := createTestManagerConfig(t) config := createTestManagerConfig(t)
// Mock successful operations // Mock successful operations
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
Return(nil) Return(nil)
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")).
Return(nil) Return(nil)
ctx := context.Background() ctx := context.Background()
@ -204,20 +232,22 @@ func TestApplyDeploymentSuccess(t *testing.T) {
// Check that operations were logged // Check that operations were logged
assert.Greater(t, len(logger.messages), 0) assert.Greater(t, len(logger.messages), 0)
mockClient.AssertExpectations(t) mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
} }
func TestApplyDeploymentAppFailure(t *testing.T) { func TestApplyDeploymentAppFailure(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
mockAppInstRepo := &MockResourceClient{}
logger := &TestLogger{} logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan() plan := createTestDeploymentPlan()
config := createTestManagerConfig(t) config := createTestManagerConfig(t)
// Mock app creation failure - deployment should stop here // Mock app creation failure - deployment should stop here
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}) Return(fmt.Errorf("Server error"))
ctx := context.Background() ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content") result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
@ -229,25 +259,27 @@ func TestApplyDeploymentAppFailure(t *testing.T) {
assert.Len(t, result.FailedActions, 1) assert.Len(t, result.FailedActions, 1)
assert.Contains(t, err.Error(), "Server error") assert.Contains(t, err.Error(), "Server error")
mockClient.AssertExpectations(t) mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
} }
func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) { func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
mockAppInstRepo := &MockResourceClient{}
logger := &TestLogger{} logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig())) manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan() plan := createTestDeploymentPlan()
config := createTestManagerConfig(t) config := createTestManagerConfig(t)
// Mock successful app creation but failed instance creation // Mock successful app creation but failed instance creation
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
Return(nil) Return(nil)
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")).
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}}) Return(fmt.Errorf("Instance creation failed"))
// Mock rollback operations // Mock rollback operations
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")).
Return(nil) Return(nil)
ctx := context.Background() ctx := context.Background()
@ -262,12 +294,14 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
assert.True(t, result.RollbackSuccess) assert.True(t, result.RollbackSuccess)
assert.Contains(t, err.Error(), "failed to create instance") assert.Contains(t, err.Error(), "failed to create instance")
mockClient.AssertExpectations(t) mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
} }
func TestApplyDeploymentNoActions(t *testing.T) { func TestApplyDeploymentNoActions(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
manager := NewResourceManager(mockClient) mockAppInstRepo := &MockResourceClient{}
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
// Create empty plan // Create empty plan
plan := &DeploymentPlan{ plan := &DeploymentPlan{
@ -283,14 +317,15 @@ func TestApplyDeploymentNoActions(t *testing.T) {
require.NotNil(t, result) require.NotNil(t, result)
assert.Contains(t, err.Error(), "deployment plan is empty") assert.Contains(t, err.Error(), "deployment plan is empty")
mockClient.AssertNotCalled(t, "CreateApp") mockAppRepo.AssertNotCalled(t, "CreateApp")
mockClient.AssertNotCalled(t, "CreateAppInstance") mockAppInstRepo.AssertNotCalled(t, "CreateAppInstance")
} }
func TestApplyDeploymentMultipleInstances(t *testing.T) { func TestApplyDeploymentMultipleInstances(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
mockAppInstRepo := &MockResourceClient{}
logger := &TestLogger{} logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig())) manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig()))
// Create plan with multiple instances // Create plan with multiple instances
plan := &DeploymentPlan{ plan := &DeploymentPlan{
@ -333,9 +368,9 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
config := createTestManagerConfig(t) config := createTestManagerConfig(t)
// Mock successful operations // Mock successful operations
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")). mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
Return(nil) Return(nil)
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")). mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")).
Return(nil) Return(nil)
ctx := context.Background() ctx := context.Background()
@ -347,12 +382,14 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
assert.Len(t, result.CompletedActions, 3) // 1 app + 2 instances assert.Len(t, result.CompletedActions, 3) // 1 app + 2 instances
assert.Len(t, result.FailedActions, 0) assert.Len(t, result.FailedActions, 0)
mockClient.AssertExpectations(t) mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
} }
func TestValidatePrerequisites(t *testing.T) { func TestValidatePrerequisites(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
manager := NewResourceManager(mockClient) mockAppInstRepo := &MockResourceClient{}
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
tests := []struct { tests := []struct {
name string name string
@ -397,9 +434,10 @@ func TestValidatePrerequisites(t *testing.T) {
} }
func TestRollbackDeployment(t *testing.T) { func TestRollbackDeployment(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
mockAppInstRepo := &MockResourceClient{}
logger := &TestLogger{} logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig())) manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
// Create result with completed actions // Create result with completed actions
plan := createTestDeploymentPlan() plan := createTestDeploymentPlan()
@ -421,24 +459,26 @@ func TestRollbackDeployment(t *testing.T) {
} }
// Mock rollback operations // Mock rollback operations
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US"). mockAppInstRepo.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppInstanceKey")).
Return(nil) Return(nil)
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")).
Return(nil) Return(nil)
ctx := context.Background() ctx := context.Background()
err := manager.RollbackDeployment(ctx, result) err := manager.RollbackDeployment(ctx, result)
require.NoError(t, err) require.NoError(t, err)
mockClient.AssertExpectations(t) mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
// Check rollback was logged // Check rollback was logged
assert.Greater(t, len(logger.messages), 0) assert.Greater(t, len(logger.messages), 0)
} }
func TestRollbackDeploymentFailure(t *testing.T) { func TestRollbackDeploymentFailure(t *testing.T) {
mockClient := &MockResourceClient{} mockAppRepo := &MockResourceClient{}
manager := NewResourceManager(mockClient) mockAppInstRepo := &MockResourceClient{}
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
plan := createTestDeploymentPlan() plan := createTestDeploymentPlan()
result := &ExecutionResult{ result := &ExecutionResult{
@ -453,15 +493,16 @@ func TestRollbackDeploymentFailure(t *testing.T) {
} }
// Mock rollback failure // Mock rollback failure
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US"). mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")).
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Delete failed"}}) Return(fmt.Errorf("Delete failed"))
ctx := context.Background() ctx := context.Background()
err := manager.RollbackDeployment(ctx, result) err := manager.RollbackDeployment(ctx, result)
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "rollback encountered") assert.Contains(t, err.Error(), "rollback encountered")
mockClient.AssertExpectations(t) mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
} }
func TestConvertNetworkRules(t *testing.T) { func TestConvertNetworkRules(t *testing.T) {

View file

@ -0,0 +1,138 @@
package apply
import (
"context"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
"github.com/stretchr/testify/mock"
)
// MockAppRepository is a mock implementation of driven.AppRepository
type MockAppRepository struct {
mock.Mock
}
func (m *MockAppRepository) CreateApp(ctx context.Context, region string, app *domain.App) error {
args := m.Called(ctx, region, app)
return args.Error(0)
}
func (m *MockAppRepository) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) {
args := m.Called(ctx, region, appKey)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.App), args.Error(1)
}
func (m *MockAppRepository) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) {
args := m.Called(ctx, region, appKey)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.App), args.Error(1)
}
func (m *MockAppRepository) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error {
args := m.Called(ctx, region, appKey)
return args.Error(0)
}
func (m *MockAppRepository) UpdateApp(ctx context.Context, region string, app *domain.App) error {
args := m.Called(ctx, region, app)
return args.Error(0)
}
// MockAppInstanceRepository is a mock implementation of driven.AppInstanceRepository
type MockAppInstanceRepository struct {
mock.Mock
}
func (m *MockAppInstanceRepository) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
args := m.Called(ctx, region, appInst)
return args.Error(0)
}
func (m *MockAppInstanceRepository) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) {
args := m.Called(ctx, region, appInstKey)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.AppInstance), args.Error(1)
}
func (m *MockAppInstanceRepository) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) {
args := m.Called(ctx, region, appInstKey)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.AppInstance), args.Error(1)
}
func (m *MockAppInstanceRepository) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
args := m.Called(ctx, region, appInstKey)
return args.Error(0)
}
func (m *MockAppInstanceRepository) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
args := m.Called(ctx, region, appInst)
return args.Error(0)
}
func (m *MockAppInstanceRepository) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
args := m.Called(ctx, region, appInstKey)
return args.Error(0)
}
// MockConfigRepository is a mock implementation of driven.ConfigRepository
type MockConfigRepository struct {
mock.Mock
}
func (m *MockConfigRepository) ParseFile(path string) (*config.EdgeConnectConfig, string, error) {
args := m.Called(path)
if args.Get(0) == nil {
return nil, args.String(1), args.Error(2)
}
return args.Get(0).(*config.EdgeConnectConfig), args.String(1), args.Error(2)
}
func (m *MockConfigRepository) Validate(cfg *config.EdgeConnectConfig) error {
args := m.Called(cfg)
return args.Error(0)
}
// NewTestPlanner creates a planner with mock repositories for testing
func NewTestPlanner(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) Planner {
if appRepo == nil {
appRepo = new(MockAppRepository)
}
if appInstRepo == nil {
appInstRepo = new(MockAppInstanceRepository)
}
return NewPlanner(appRepo, appInstRepo)
}
// NewTestResourceManager creates a resource manager with mock repositories for testing
func NewTestResourceManager(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) ResourceManagerInterface {
if appRepo == nil {
appRepo = new(MockAppRepository)
}
if appInstRepo == nil {
appInstRepo = new(MockAppInstanceRepository)
}
return NewResourceManager(appRepo, appInstRepo)
}
// NewTestStrategyFactory creates a strategy factory with mock repositories for testing
func NewTestStrategyFactory(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) *StrategyFactory {
if appRepo == nil {
appRepo = new(MockAppRepository)
}
if appInstRepo == nil {
appInstRepo = new(MockAppInstanceRepository)
}
return NewStrategyFactory(appRepo, appInstRepo, DefaultStrategyConfig(), nil)
}

View file

@ -1,6 +1,6 @@
// ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison // ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison
// ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls // ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls
package v2 package apply
import ( import (
"context" "context"
@ -11,22 +11,11 @@ import (
"strings" "strings"
"time" "time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
) )
// EdgeConnectClientInterface defines the methods needed for deployment planning
type EdgeConnectClientInterface interface {
ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error)
CreateApp(ctx context.Context, input *v2.NewAppInput) error
UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error
DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error
ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) (v2.AppInstance, error)
CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error
UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error
DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error
}
// Planner defines the interface for deployment planning // Planner defines the interface for deployment planning
type Planner interface { type Planner interface {
// Plan analyzes the configuration and current state to generate a deployment plan // Plan analyzes the configuration and current state to generate a deployment plan
@ -67,13 +56,15 @@ func DefaultPlanOptions() PlanOptions {
// EdgeConnectPlanner implements the Planner interface for EdgeConnect // EdgeConnectPlanner implements the Planner interface for EdgeConnect
type EdgeConnectPlanner struct { type EdgeConnectPlanner struct {
client EdgeConnectClientInterface appRepo driven.AppRepository
appInstRepo driven.AppInstanceRepository
} }
// NewPlanner creates a new EdgeConnect deployment planner // NewPlanner creates a new EdgeConnect deployment planner
func NewPlanner(client EdgeConnectClientInterface) Planner { func NewPlanner(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) Planner {
return &EdgeConnectPlanner{ return &EdgeConnectPlanner{
client: client, appRepo: appRepo,
appInstRepo: appInstRepo,
} }
} }
@ -135,9 +126,9 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E
desired := &AppState{ desired := &AppState{
Name: config.Metadata.Name, Name: config.Metadata.Name,
Version: config.Metadata.AppVersion, Version: config.Metadata.AppVersion,
Organization: config.Metadata.Organization, // Use first infra template for org Organization: config.Metadata.Organization, // Use first infra template for org
Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region
Exists: false, // Will be set based on current state Exists: false, // Will be set based on current state
} }
if config.Spec.IsK8sApp() { if config.Spec.IsK8sApp() {
@ -148,9 +139,9 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E
// Extract outbound connections from config // Extract outbound connections from config
if config.Spec.Network != nil { if config.Spec.Network != nil {
desired.OutboundConnections = make([]SecurityRule, len(config.Spec.Network.OutboundConnections)) desired.OutboundConnections = make([]domain.SecurityRule, len(config.Spec.Network.OutboundConnections))
for i, conn := range config.Spec.Network.OutboundConnections { for i, conn := range config.Spec.Network.OutboundConnections {
desired.OutboundConnections[i] = SecurityRule{ desired.OutboundConnections[i] = domain.SecurityRule{
Protocol: conn.Protocol, Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin, PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax, PortRangeMax: conn.PortRangeMax,
@ -285,13 +276,13 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
timeoutCtx, cancel := context.WithTimeout(ctx, timeout) timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
appKey := v2.AppKey{ appKey := domain.AppKey{
Organization: desired.Organization, Organization: desired.Organization,
Name: desired.Name, Name: desired.Name,
Version: desired.Version, Version: desired.Version,
} }
app, err := p.client.ShowApp(timeoutCtx, appKey, desired.Region) app, err := p.appRepo.ShowApp(timeoutCtx, desired.Region, appKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -321,9 +312,14 @@ 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([]domain.SecurityRule, len(app.RequiredOutboundConnections))
for i, conn := range app.RequiredOutboundConnections { for i, conn := range app.RequiredOutboundConnections {
current.OutboundConnections[i] = SecurityRule(conn) current.OutboundConnections[i] = domain.SecurityRule{
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
} }
return current, nil return current, nil
@ -334,18 +330,16 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire
timeoutCtx, cancel := context.WithTimeout(ctx, timeout) timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
instanceKey := v2.AppInstanceKey{ instanceKey := domain.AppInstanceKey{
Organization: desired.Organization, Organization: desired.Organization,
Name: desired.Name, Name: desired.Name,
CloudletKey: v2.CloudletKey{ CloudletKey: domain.CloudletKey{
Organization: desired.CloudletOrg, Organization: desired.CloudletOrg,
Name: desired.CloudletName, Name: desired.CloudletName,
}, },
} }
appKey := v2.AppKey{Name: desired.AppName} instance, err := p.appInstRepo.ShowAppInstance(timeoutCtx, desired.Region, instanceKey)
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -389,7 +383,7 @@ func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]str
// Compare outbound connections // Compare outbound connections
outboundChanges := p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections) outboundChanges := p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections)
if len(outboundChanges) > 0 { if len(outboundChanges) > 0 {
sb := strings.Builder{} sb:= strings.Builder{}
sb.WriteString("Outbound connections changed:\n") sb.WriteString("Outbound connections changed:\n")
for _, change := range outboundChanges { for _, change := range outboundChanges {
sb.WriteString(change) sb.WriteString(change)
@ -402,10 +396,10 @@ func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]str
} }
// compareOutboundConnections compares two sets of outbound connections for equality // compareOutboundConnections compares two sets of outbound connections for equality
func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) []string { func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []domain.SecurityRule) []string {
var changes []string var changes []string
makeMap := func(rules []SecurityRule) map[string]SecurityRule { makeMap := func(rules []domain.SecurityRule) map[string]domain.SecurityRule {
m := make(map[string]SecurityRule, len(rules)) m := make(map[string]domain.SecurityRule, len(rules))
for _, r := range rules { for _, r := range rules {
key := fmt.Sprintf("%s:%d-%d:%s", key := fmt.Sprintf("%s:%d-%d:%s",
strings.ToLower(r.Protocol), strings.ToLower(r.Protocol),
@ -468,7 +462,10 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string,
return "", fmt.Errorf("failed to open manifest file: %w", err) return "", fmt.Errorf("failed to open manifest file: %w", err)
} }
defer func() { defer func() {
_ = file.Close() if err := file.Close(); err != nil {
// Log error but don't fail the operation as hash is already computed
fmt.Fprintf(os.Stderr, "Warning: failed to close manifest file: %v\n", err)
}
}() }()
hasher := sha256.New() hasher := sha256.New()
@ -553,4 +550,4 @@ func max(a, b time.Duration) time.Duration {
// getInstanceName generates the instance name following the pattern: appName-appVersion-instance // getInstanceName generates the instance name following the pattern: appName-appVersion-instance
func getInstanceName(appName, appVersion string) string { func getInstanceName(appName, appVersion string) string {
return fmt.Sprintf("%s-%s-instance", appName, appVersion) return fmt.Sprintf("%s-%s-instance", appName, appVersion)
} }

View file

@ -0,0 +1,760 @@
package apply
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestPlanner_Plan_CreateApp(t *testing.T) {
mockAppRepo := new(MockAppRepository)
mockAppInstRepo := new(MockAppInstanceRepository)
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
testConfig := &config.EdgeConnectConfig{
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "test-org",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{},
InfraTemplate: []config.InfraTemplate{
{
Region: "us-west",
CloudletOrg: "cloudlet-org",
CloudletName: "cloudlet-name",
FlavorName: "m4.small",
},
},
},
}
// Mock app not found
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
}).Return(nil, fmt.Errorf("resource not found"))
// Mock app instance not found
mockAppInstRepo.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppInstanceKey")).
Return(nil, fmt.Errorf("resource not found"))
result, err := planner.Plan(context.Background(), testConfig)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.NotNil(t, result.Plan)
assert.Equal(t, ActionCreate, result.Plan.AppAction.Type)
assert.Equal(t, "Application does not exist", result.Plan.AppAction.Reason)
assert.Len(t, result.Plan.InstanceActions, 1)
assert.Equal(t, ActionCreate, result.Plan.InstanceActions[0].Type)
assert.Equal(t, "Instance does not exist", result.Plan.InstanceActions[0].Reason)
assert.Equal(t, 2, result.Plan.TotalActions)
mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
}
func TestPlanner_Plan_UpdateApp(t *testing.T) {
mockAppRepo := new(MockAppRepository)
mockAppInstRepo := new(MockAppInstanceRepository)
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
testConfig := &config.EdgeConnectConfig{
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "test-org",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{},
InfraTemplate: []config.InfraTemplate{
{
Region: "us-west",
CloudletOrg: "cloudlet-org",
CloudletName: "cloudlet-name",
FlavorName: "m4.small",
},
},
Network: &config.NetworkConfig{
OutboundConnections: []config.OutboundConnection{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
},
},
}
existingApp := &domain.App{
Key: domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
},
Deployment: "kubernetes",
RequiredOutboundConnections: []domain.SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 8080,
PortRangeMax: 8080,
RemoteCIDR: "0.0.0.0/0",
},
},
}
// Mock app found
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
}).Return(existingApp, nil)
// Mock instance not found
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
Organization: "test-org",
Name: "test-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org",
Name: "cloudlet-name",
},
}).Return(nil, fmt.Errorf("resource not found"))
result, err := planner.Plan(context.Background(), testConfig)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.NotNil(t, result.Plan)
assert.Equal(t, ActionUpdate, result.Plan.AppAction.Type)
assert.Contains(t, result.Plan.AppAction.Reason, "Application configuration has changed")
assert.Len(t, result.Plan.InstanceActions, 1)
assert.Equal(t, ActionCreate, result.Plan.InstanceActions[0].Type)
assert.Equal(t, 2, result.Plan.TotalActions)
mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
}
func TestPlanner_Plan_NoChange(t *testing.T) {
mockAppRepo := new(MockAppRepository)
mockAppInstRepo := new(MockAppInstanceRepository)
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
testConfig := &config.EdgeConnectConfig{
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "test-org",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{},
InfraTemplate: []config.InfraTemplate{
{
Region: "us-west",
CloudletOrg: "cloudlet-org",
CloudletName: "cloudlet-name",
FlavorName: "m4.small",
},
},
Network: &config.NetworkConfig{
OutboundConnections: []config.OutboundConnection{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
},
},
}
existingApp := &domain.App{
Key: domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
},
Deployment: "kubernetes",
RequiredOutboundConnections: []domain.SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
}
existingInstance := &domain.AppInstance{
Key: domain.AppInstanceKey{
Organization: "test-org",
Name: "test-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org",
Name: "cloudlet-name",
},
},
AppKey: domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
},
Flavor: domain.Flavor{Name: "m4.small"},
}
// Mock app found
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
}).Return(existingApp, nil)
// Mock instance found
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
Organization: "test-org", Name: "test-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org",
Name: "cloudlet-name",
},
}).Return(existingInstance, nil)
result, err := planner.Plan(context.Background(), testConfig)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.NotNil(t, result.Plan)
assert.Equal(t, ActionNone, result.Plan.AppAction.Type)
assert.Equal(t, ActionNone, result.Plan.InstanceActions[0].Type)
assert.Equal(t, 0, result.Plan.TotalActions)
mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
}
func TestPlanner_Plan_SkipStateCheck(t *testing.T) {
mockAppRepo := new(MockAppRepository)
mockAppInstRepo := new(MockAppInstanceRepository)
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
testConfig := &config.EdgeConnectConfig{
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "test-org",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{},
InfraTemplate: []config.InfraTemplate{
{
Region: "us-west",
CloudletOrg: "cloudlet-org",
CloudletName: "cloudlet-name",
FlavorName: "m4.small",
},
},
},
}
opts := DefaultPlanOptions()
opts.SkipStateCheck = true
result, err := planner.PlanWithOptions(context.Background(), testConfig, opts)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.NotNil(t, result.Plan)
assert.Equal(t, ActionCreate, result.Plan.AppAction.Type)
assert.Equal(t, "Creating app (state check skipped)", result.Plan.AppAction.Reason)
assert.Len(t, result.Plan.InstanceActions, 1)
assert.Equal(t, ActionCreate, result.Plan.InstanceActions[0].Type)
assert.Equal(t, "Creating instance (state check skipped)", result.Plan.InstanceActions[0].Reason)
assert.Equal(t, 2, result.Plan.TotalActions)
mockAppRepo.AssertNotCalled(t, "ShowApp", mock.Anything, mock.Anything, mock.Anything)
mockAppInstRepo.AssertNotCalled(t, "ShowAppInstance", mock.Anything, mock.Anything, mock.Anything)
}
func TestPlanner_Plan_ManifestHashChange(t *testing.T) {
mockAppRepo := new(MockAppRepository)
mockAppInstRepo := new(MockAppInstanceRepository)
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
// Create a temporary manifest file
tempDir := t.TempDir()
manifestPath := filepath.Join(tempDir, "manifest.yaml")
err := os.WriteFile(manifestPath, []byte("new manifest content"), 0644)
assert.NoError(t, err)
testConfig := &config.EdgeConnectConfig{
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "test-org",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{
ManifestFile: manifestPath,
},
InfraTemplate: []config.InfraTemplate{
{
Region: "us-west",
CloudletOrg: "cloudlet-org",
CloudletName: "cloudlet-name",
FlavorName: "m4.small",
},
},
},
}
existingApp := &domain.App{
Key: domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
},
Deployment: "kubernetes",
DeploymentManifest: "old manifest content", // Different manifest
}
// Mock app found
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
}).Return(existingApp, nil)
// Mock instance found (no change)
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
Organization: "test-org",
Name: "test-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org",
Name: "cloudlet-name",
},
}).Return(&domain.AppInstance{
Key: domain.AppInstanceKey{
Organization: "test-org",
Name: "test-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org",
Name: "cloudlet-name",
},
},
AppKey: domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
},
Flavor: domain.Flavor{Name: "m4.small"},
}, nil)
result, err := planner.Plan(context.Background(), testConfig)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.NotNil(t, result.Plan)
assert.Equal(t, ActionUpdate, result.Plan.AppAction.Type)
assert.True(t, result.Plan.AppAction.ManifestChanged)
assert.Contains(t, result.Plan.AppAction.Reason, "Application configuration has changed")
assert.Contains(t, result.Warnings, "Manifest file has changed - instances may need to be recreated")
assert.Equal(t, 1, result.Plan.TotalActions) // Only app update, instance is ActionNone
mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
}
func TestPlanner_Plan_InstanceFlavorChange(t *testing.T) {
mockAppRepo := new(MockAppRepository)
mockAppInstRepo := new(MockAppInstanceRepository)
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
testConfig := &config.EdgeConnectConfig{
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "test-org",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{},
InfraTemplate: []config.InfraTemplate{
{
Region: "us-west",
CloudletOrg: "cloudlet-org",
CloudletName: "cloudlet-name",
FlavorName: "m4.large", // Changed flavor
},
},
},
}
existingApp := &domain.App{
Key: domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
},
Deployment: "kubernetes",
}
existingInstance := &domain.AppInstance{
Key: domain.AppInstanceKey{
Organization: "test-org",
Name: "test-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org",
Name: "cloudlet-name",
},
},
AppKey: domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
},
Flavor: domain.Flavor{Name: "m4.small"}, // Old flavor
}
// Mock app found
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
}).Return(existingApp, nil)
// Mock instance found
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
Organization: "test-org",
Name: "test-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org",
Name: "cloudlet-name",
},
}).Return(existingInstance, nil)
result, err := planner.Plan(context.Background(), testConfig)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.NotNil(t, result.Plan)
assert.Equal(t, ActionNone, result.Plan.AppAction.Type)
assert.Len(t, result.Plan.InstanceActions, 1)
assert.Equal(t, ActionUpdate, result.Plan.InstanceActions[0].Type)
assert.Contains(t, result.Plan.InstanceActions[0].Reason, "Instance configuration has changed")
assert.Contains(t, result.Plan.InstanceActions[0].Changes, "Flavor changed: m4.small -> m4.large")
assert.Equal(t, 1, result.Plan.TotalActions)
mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
}
func TestPlanner_Plan_MultipleInstances(t *testing.T) {
mockAppRepo := new(MockAppRepository)
mockAppInstRepo := new(MockAppInstanceRepository)
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
testConfig := &config.EdgeConnectConfig{
Metadata: config.Metadata{
Name: "multi-app",
AppVersion: "1.0.0",
Organization: "test-org",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{},
InfraTemplate: []config.InfraTemplate{
{
Region: "us-west",
CloudletOrg: "cloudlet-org-1",
CloudletName: "cloudlet-name-1",
FlavorName: "m4.small",
},
{
Region: "us-east",
CloudletOrg: "cloudlet-org-2",
CloudletName: "cloudlet-name-2",
FlavorName: "m4.medium",
},
},
},
}
existingApp := &domain.App{
Key: domain.AppKey{
Organization: "test-org",
Name: "multi-app",
Version: "1.0.0",
},
Deployment: "kubernetes",
}
// Mock app found
mockAppRepo.On("ShowApp", mock.Anything, mock.AnythingOfType("string"), domain.AppKey{
Organization: "test-org",
Name: "multi-app",
Version: "1.0.0",
}).Return(existingApp, nil)
// Mock instance 1 not found
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
Organization: "test-org",
Name: "multi-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org-1",
Name: "cloudlet-name-1",
},
}).Return(nil, fmt.Errorf("resource not found"))
// Mock instance 2 found with different flavor
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-east", domain.AppInstanceKey{
Organization: "test-org",
Name: "multi-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org-2",
Name: "cloudlet-name-2",
},
}).Return(&domain.AppInstance{
Key: domain.AppInstanceKey{
Organization: "test-org",
Name: "multi-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org-2",
Name: "cloudlet-name-2",
},
},
AppKey: domain.AppKey{
Organization: "test-org",
Name: "multi-app",
Version: "1.0.0",
},
Flavor: domain.Flavor{Name: "m4.small"}, // Different flavor
}, nil)
result, err := planner.Plan(context.Background(), testConfig)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.NotNil(t, result.Plan)
assert.Equal(t, ActionNone, result.Plan.AppAction.Type)
assert.Len(t, result.Plan.InstanceActions, 2)
assert.Equal(t, ActionCreate, result.Plan.InstanceActions[0].Type) // Instance 1 is new
assert.Equal(t, ActionUpdate, result.Plan.InstanceActions[1].Type) // Instance 2 has flavor change
assert.Contains(t, result.Plan.InstanceActions[1].Changes, "Flavor changed: m4.small -> m4.medium")
assert.Equal(t, 2, result.Plan.TotalActions)
mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
}
func TestPlanner_Plan_AppQueryError(t *testing.T) {
mockAppRepo := new(MockAppRepository)
mockAppInstRepo := new(MockAppInstanceRepository)
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
testConfig := &config.EdgeConnectConfig{
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "test-org",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{},
InfraTemplate: []config.InfraTemplate{
{
Region: "us-west",
CloudletOrg: "cloudlet-org",
CloudletName: "cloudlet-name",
FlavorName: "m4.small",
},
},
},
}
// Mock app query error
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
}).Return(nil, fmt.Errorf("network error"))
result, err := planner.Plan(context.Background(), testConfig)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to query current app state: network error")
assert.NotNil(t, result)
mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
}
func TestPlanner_Plan_InstanceQueryError(t *testing.T) {
mockAppRepo := new(MockAppRepository)
mockAppInstRepo := new(MockAppInstanceRepository)
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
testConfig := &config.EdgeConnectConfig{
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "test-org",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{},
InfraTemplate: []config.InfraTemplate{
{
Region: "us-west",
CloudletOrg: "cloudlet-org",
CloudletName: "cloudlet-name",
FlavorName: "m4.small",
},
},
},
}
existingApp := &domain.App{
Key: domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
},
Deployment: "kubernetes",
}
// Mock app found
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
}).Return(existingApp, nil)
// Mock instance query error
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
Organization: "test-org",
Name: "test-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org",
Name: "cloudlet-name",
},
}).Return(nil, fmt.Errorf("database error"))
result, err := planner.Plan(context.Background(), testConfig)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to query current instance state: database error")
assert.NotNil(t, result)
mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
}
func TestPlanner_Plan_ManifestFileError(t *testing.T) {
mockAppRepo := new(MockAppRepository)
mockAppInstRepo := new(MockAppInstanceRepository)
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
testConfig := &config.EdgeConnectConfig{
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "test-org",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{
ManifestFile: "/non/existent/path/manifest.yaml", // Invalid path
},
InfraTemplate: []config.InfraTemplate{
{
Region: "us-west",
CloudletOrg: "cloudlet-org",
CloudletName: "cloudlet-name",
FlavorName: "m4.small",
},
},
},
}
result, err := planner.Plan(context.Background(), testConfig)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to calculate manifest hash: failed to open manifest file")
assert.NotNil(t, result)
mockAppRepo.AssertNotCalled(t, "ShowApp", mock.Anything, mock.Anything, mock.Anything)
mockAppInstRepo.AssertNotCalled(t, "ShowAppInstance", mock.Anything, mock.Anything, mock.Anything)
}
func TestPlanner_Plan_AppTypeChange(t *testing.T) {
mockAppRepo := new(MockAppRepository)
mockAppInstRepo := new(MockAppInstanceRepository)
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
testConfig := &config.EdgeConnectConfig{
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "test-org",
},
Spec: config.Spec{
DockerApp: &config.DockerApp{},
InfraTemplate: []config.InfraTemplate{
{
Region: "us-west",
CloudletOrg: "cloudlet-org",
CloudletName: "cloudlet-name",
FlavorName: "m4.small",
},
},
},
}
existingApp := &domain.App{
Key: domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
},
Deployment: "kubernetes", // Current is kubernetes
}
// Mock app found
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
}).Return(existingApp, nil)
// Mock instance found (no change)
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
Organization: "test-org",
Name: "test-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org",
Name: "cloudlet-name",
},
}).Return(&domain.AppInstance{
Key: domain.AppInstanceKey{
Organization: "test-org",
Name: "test-app-1.0.0-instance",
CloudletKey: domain.CloudletKey{
Organization: "cloudlet-org",
Name: "cloudlet-name",
},
},
AppKey: domain.AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
},
Flavor: domain.Flavor{Name: "m4.small"},
}, nil)
result, err := planner.Plan(context.Background(), testConfig)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.NotNil(t, result.Plan)
assert.Equal(t, ActionUpdate, result.Plan.AppAction.Type)
assert.Contains(t, result.Plan.AppAction.Changes, "App type changed: k8s -> docker")
assert.Equal(t, 1, result.Plan.TotalActions) // Only app update, instance is ActionNone
mockAppRepo.AssertExpectations(t)
mockAppInstRepo.AssertExpectations(t)
}

View file

@ -1,13 +1,14 @@
// ABOUTME: Deployment strategy framework for EdgeConnect apply command // ABOUTME: Deployment strategy framework for EdgeConnect apply command
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling) // ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
package v2 package apply
import ( import (
"context" "context"
"fmt" "fmt"
"time" "time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
) )
// DeploymentStrategy represents the type of deployment strategy // DeploymentStrategy represents the type of deployment strategy
@ -66,17 +67,19 @@ func DefaultStrategyConfig() StrategyConfig {
// StrategyFactory creates deployment strategy executors // StrategyFactory creates deployment strategy executors
type StrategyFactory struct { type StrategyFactory struct {
config StrategyConfig config StrategyConfig
client EdgeConnectClientInterface appRepo driven.AppRepository
logger Logger appInstRepo driven.AppInstanceRepository
logger Logger
} }
// NewStrategyFactory creates a new strategy factory // NewStrategyFactory creates a new strategy factory
func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *StrategyFactory { func NewStrategyFactory(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, config StrategyConfig, logger Logger) *StrategyFactory {
return &StrategyFactory{ return &StrategyFactory{
config: config, config: config,
client: client, appRepo: appRepo,
logger: logger, appInstRepo: appInstRepo,
logger: logger,
} }
} }
@ -84,7 +87,7 @@ func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig
func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) { func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) {
switch strategy { switch strategy {
case StrategyRecreate: case StrategyRecreate:
return NewRecreateStrategy(f.client, f.config, f.logger), nil return NewRecreateStrategy(f.appRepo, f.appInstRepo, f.config, f.logger), nil
case StrategyBlueGreen: case StrategyBlueGreen:
// TODO: Implement blue-green strategy // TODO: Implement blue-green strategy
return nil, fmt.Errorf("blue-green strategy not yet implemented") return nil, fmt.Errorf("blue-green strategy not yet implemented")
@ -103,4 +106,4 @@ func (f *StrategyFactory) GetAvailableStrategies() []DeploymentStrategy {
// StrategyBlueGreen, // TODO: Enable when implemented // StrategyBlueGreen, // TODO: Enable when implemented
// StrategyRolling, // TODO: Enable when implemented // StrategyRolling, // TODO: Enable when implemented
} }
} }

View file

@ -1,32 +1,34 @@
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect // ABOUTME: Recreate deployment strategy implementation for EdgeConnect
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution // ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
package v1 package apply
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
"time" "time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
) )
// RecreateStrategy implements the recreate deployment strategy // RecreateStrategy implements the recreate deployment strategy
type RecreateStrategy struct { type RecreateStrategy struct {
client EdgeConnectClientInterface appRepo driven.AppRepository
config StrategyConfig appInstRepo driven.AppInstanceRepository
logger Logger config StrategyConfig
logger Logger
} }
// NewRecreateStrategy creates a new recreate strategy executor // NewRecreateStrategy creates a new recreate strategy executor
func NewRecreateStrategy(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *RecreateStrategy { func NewRecreateStrategy(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, config StrategyConfig, logger Logger) *RecreateStrategy {
return &RecreateStrategy{ return &RecreateStrategy{
client: client, appRepo: appRepo,
config: config, appInstRepo: appInstRepo,
logger: logger, config: config,
logger: logger,
} }
} }
@ -184,13 +186,13 @@ func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentP
r.logf("Phase 2: Deleting existing application") r.logf("Phase 2: Deleting existing application")
appKey := edgeconnect.AppKey{ appKey := domain.AppKey{
Organization: plan.AppAction.Desired.Organization, Organization: plan.AppAction.Desired.Organization,
Name: plan.AppAction.Desired.Name, Name: plan.AppAction.Desired.Name,
Version: plan.AppAction.Desired.Version, Version: plan.AppAction.Desired.Version,
} }
if err := r.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region); err != nil { if err := r.appRepo.DeleteApp(ctx, plan.AppAction.Desired.Region, appKey); err != nil {
result.FailedActions = append(result.FailedActions, ActionResult{ result.FailedActions = append(result.FailedActions, ActionResult{
Type: ActionDelete, Type: ActionDelete,
Target: plan.AppAction.Desired.Name, Target: plan.AppAction.Desired.Name,
@ -356,15 +358,6 @@ func (r *RecreateStrategy) executeInstanceActionWithRetry(ctx context.Context, a
} }
lastErr = err lastErr = err
// Check if error is retryable (don't retry 4xx client errors)
if !isRetryableError(err) {
r.logf("Failed to %s instance %s: %v (non-retryable error, giving up)", operation, action.InstanceName, err)
result.Error = fmt.Errorf("non-retryable error: %w", err)
result.Duration = time.Since(startTime)
return result
}
if attempt < r.config.MaxRetries { if attempt < r.config.MaxRetries {
r.logf("Failed to %s instance %s: %v (will retry)", operation, action.InstanceName, err) r.logf("Failed to %s instance %s: %v (will retry)", operation, action.InstanceName, err)
} }
@ -396,7 +389,12 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action
} }
} }
success, err := r.updateApplication(ctx, action, config, manifestContent) var success bool
var err error
// For recreate strategy, we always create the app
success, err = r.createApplication(ctx, action, config, manifestContent)
if success { if success {
result.Success = true result.Success = true
result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name) result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name)
@ -405,15 +403,6 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action
} }
lastErr = err lastErr = err
// Check if error is retryable (don't retry 4xx client errors)
if !isRetryableError(err) {
r.logf("Failed to update app: %v (non-retryable error, giving up)", err)
result.Error = fmt.Errorf("non-retryable error: %w", err)
result.Duration = time.Since(startTime)
return result
}
if attempt < r.config.MaxRetries { if attempt < r.config.MaxRetries {
r.logf("Failed to update app: %v (will retry)", err) r.logf("Failed to update app: %v (will retry)", err)
} }
@ -426,16 +415,16 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action
// deleteInstance deletes an instance (reuse existing logic from manager.go) // deleteInstance deletes an instance (reuse existing logic from manager.go)
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) { func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
instanceKey := edgeconnect.AppInstanceKey{ instanceKey := domain.AppInstanceKey{
Organization: action.Desired.Organization, Organization: action.Desired.Organization,
Name: action.InstanceName, Name: action.InstanceName,
CloudletKey: edgeconnect.CloudletKey{ CloudletKey: domain.CloudletKey{
Organization: action.Target.CloudletOrg, Organization: action.Target.CloudletOrg,
Name: action.Target.CloudletName, Name: action.Target.CloudletName,
}, },
} }
err := r.client.DeleteAppInstance(ctx, instanceKey, action.Target.Region) err := r.appInstRepo.DeleteAppInstance(ctx, action.Target.Region, instanceKey)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to delete instance: %w", err) return false, fmt.Errorf("failed to delete instance: %w", err)
} }
@ -445,30 +434,27 @@ func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAc
// createInstance creates an instance (extracted from manager.go logic) // createInstance creates an instance (extracted from manager.go logic)
func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) { func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
instanceInput := &edgeconnect.NewAppInstanceInput{ appInst := &domain.AppInstance{
Region: action.Target.Region, Key: domain.AppInstanceKey{
AppInst: edgeconnect.AppInstance{ Organization: action.Desired.Organization,
Key: edgeconnect.AppInstanceKey{ Name: action.InstanceName,
Organization: action.Desired.Organization, CloudletKey: domain.CloudletKey{
Name: action.InstanceName, Organization: action.Target.CloudletOrg,
CloudletKey: edgeconnect.CloudletKey{ Name: action.Target.CloudletName,
Organization: action.Target.CloudletOrg,
Name: action.Target.CloudletName,
},
},
AppKey: edgeconnect.AppKey{
Organization: action.Desired.Organization,
Name: config.Metadata.Name,
Version: config.Metadata.AppVersion,
},
Flavor: edgeconnect.Flavor{
Name: action.Target.FlavorName,
}, },
}, },
AppKey: domain.AppKey{
Organization: action.Desired.Organization,
Name: config.Metadata.Name,
Version: config.Metadata.AppVersion,
},
Flavor: domain.Flavor{
Name: action.Target.FlavorName,
},
} }
// Create the instance // Create the instance
if err := r.client.CreateAppInstance(ctx, instanceInput); err != nil { if err := r.appInstRepo.CreateAppInstance(ctx, action.Target.Region, appInst); err != nil {
return false, fmt.Errorf("failed to create instance: %w", err) return false, fmt.Errorf("failed to create instance: %w", err)
} }
@ -478,35 +464,30 @@ func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAc
return true, nil return true, nil
} }
// updateApplication creates/recreates an application (always uses CreateApp since we delete first) // createApplication creates/recreates an application (always uses CreateApp since we delete first)
func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) { func (r *RecreateStrategy) createApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) {
// Build the app create input - always create since recreate strategy deletes first app := &domain.App{
appInput := &edgeconnect.NewAppInput{ Key: domain.AppKey{
Region: action.Desired.Region, Organization: action.Desired.Organization,
App: edgeconnect.App{ Name: action.Desired.Name,
Key: edgeconnect.AppKey{ Version: action.Desired.Version,
Organization: action.Desired.Organization,
Name: action.Desired.Name,
Version: action.Desired.Version,
},
Deployment: config.GetDeploymentType(),
ImageType: "ImageTypeDocker",
ImagePath: config.GetImagePath(),
AllowServerless: true,
DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
ServerlessConfig: struct{}{},
DeploymentManifest: manifestContent,
DeploymentGenerator: "kubernetes-basic",
}, },
Deployment: config.GetDeploymentType(),
ImagePath: config.GetImagePath(),
AllowServerless: true,
DefaultFlavor: domain.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
ServerlessConfig: struct{}{},
DeploymentManifest: manifestContent,
DeploymentGenerator: "kubernetes-basic",
} }
// Add network configuration if specified // Add network configuration if specified
if config.Spec.Network != nil { if config.Spec.Network != nil {
appInput.App.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network) app.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network)
} }
// Create the application (recreate strategy always creates from scratch) // Create the application (recreate strategy always creates from scratch)
if err := r.client.CreateApp(ctx, appInput); err != nil { if err := r.appRepo.CreateApp(ctx, action.Desired.Region, app); err != nil {
return false, fmt.Errorf("failed to create application: %w", err) return false, fmt.Errorf("failed to create application: %w", err)
} }
@ -516,33 +497,27 @@ func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppActi
return true, nil return true, nil
} }
// convertNetworkRules converts config.NetworkConfig to []domain.SecurityRule
func convertNetworkRules(network *config.NetworkConfig) []domain.SecurityRule {
if network == nil || len(network.OutboundConnections) == 0 {
return nil
}
rules := make([]domain.SecurityRule, len(network.OutboundConnections))
for i, conn := range network.OutboundConnections {
rules[i] = domain.SecurityRule{
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
}
return rules
}
// 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 {
r.logger.Printf("[RecreateStrategy] "+format, v...) r.logger.Printf("[RecreateStrategy] "+format, v...)
} }
} }
// isRetryableError determines if an error should be retried
// Returns false for client errors (4xx), true for server errors (5xx) and other transient errors
func isRetryableError(err error) bool {
if err == nil {
return false
}
// Check if it's an APIError with a status code
var apiErr *edgeconnect.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
}

View file

@ -0,0 +1,161 @@
// ABOUTME: Core types for EdgeConnect deployment planning and execution
// ABOUTME: Defines data structures for deployment plans, actions, and results
package apply
import (
"fmt"
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
)
// ActionType defines the type of action to be performed
type ActionType string
const (
ActionNone ActionType = "NONE"
ActionCreate ActionType = "CREATE"
ActionUpdate ActionType = "UPDATE"
ActionDelete ActionType = "DELETE"
)
// AppType defines the type of application deployment
type AppType string
const (
AppTypeK8s AppType = "k8s"
AppTypeDocker AppType = "docker"
)
// AppState represents the desired or current state of an application
type AppState struct {
Name string
Version string
Organization string
Region string
AppType AppType
ManifestHash string
OutboundConnections []domain.SecurityRule
Exists bool
LastUpdated time.Time
}
// InstanceState represents the desired or current state of an application instance
type InstanceState struct {
Name string
AppName string
AppVersion string
Organization string
Region string
CloudletOrg string
CloudletName string
FlavorName string
State string
PowerState string
Exists bool
LastUpdated time.Time
}
// AppAction defines an action to be performed on an application
type AppAction struct {
Type ActionType
Desired *AppState
Current *AppState
ManifestHash string
ManifestChanged bool
Reason string
Changes []string
}
// InstanceAction defines an action to be performed on an application instance
type InstanceAction struct {
Type ActionType
Target config.InfraTemplate
Desired *InstanceState
Current *InstanceState
InstanceName string
Reason string
Changes []string
}
// DeploymentPlan represents a plan of actions to achieve the desired state
type DeploymentPlan struct {
ConfigName string
CreatedAt time.Time
DryRun bool
AppAction AppAction
InstanceActions []InstanceAction
TotalActions int
EstimatedDuration time.Duration
Summary string
}
// IsEmpty returns true if the plan contains no actions
func (p *DeploymentPlan) IsEmpty() bool {
return p.AppAction.Type == ActionNone && len(p.InstanceActions) == 0
}
// Validate checks the validity of the deployment plan
func (p *DeploymentPlan) Validate() error {
if p.AppAction.Type == ActionNone && len(p.InstanceActions) == 0 {
return fmt.Errorf("deployment plan is empty")
}
// Add more validation rules as needed
return nil
}
// GenerateSummary creates a human-readable summary of the plan
func (p *DeploymentPlan) GenerateSummary() string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Plan for '%s' (created: %s, dry-run: %t)\n", p.ConfigName, p.CreatedAt.Format(time.RFC3339), p.DryRun))
sb.WriteString("--------------------------------------------------\n")
if p.AppAction.Type != ActionNone {
sb.WriteString(fmt.Sprintf("Application '%s': %s - %s\n", p.AppAction.Desired.Name, p.AppAction.Type, p.AppAction.Reason))
for _, change := range p.AppAction.Changes {
sb.WriteString(fmt.Sprintf(" - %s\n", change))
}
}
for _, action := range p.InstanceActions {
sb.WriteString(fmt.Sprintf("Instance '%s' on '%s': %s - %s\n", action.InstanceName, action.Target.CloudletName, action.Type, action.Reason))
for _, change := range action.Changes {
sb.WriteString(fmt.Sprintf(" - %s\n", change))
}
}
sb.WriteString("--------------------------------------------------\n")
sb.WriteString(fmt.Sprintf("Total actions: %d, Estimated duration: %v\n", p.TotalActions, p.EstimatedDuration))
return sb.String()
}
// PlanResult holds the result of a planning operation
type PlanResult struct {
Plan *DeploymentPlan
Warnings []string
Error error
}
// ExecutionResult holds the result of a deployment execution
type ExecutionResult struct {
Plan *DeploymentPlan
Success bool
Error error
Duration time.Duration
CompletedActions []ActionResult
FailedActions []ActionResult
RollbackPerformed bool
RollbackSuccess bool
}
// ActionResult details the outcome of a single action
type ActionResult struct {
Type ActionType
Target string
Success bool
Error error
Details string
Duration time.Duration
}

View file

@ -0,0 +1,118 @@
package cloudlet
import (
"context"
"strings"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving"
)
type service struct {
cloudletRepo driven.CloudletRepository
}
func NewService(cloudletRepo driven.CloudletRepository) driving.CloudletService {
return &service{cloudletRepo: cloudletRepo}
}
func (s *service) CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error {
if err := s.validateCloudlet(cloudlet); err != nil {
return err
}
if region == "" {
return domain.ErrMissingRegion
}
if err := s.cloudletRepo.CreateCloudlet(ctx, region, cloudlet); err != nil {
if domain.IsNotFoundError(err) {
return domain.NewCloudletError(domain.ErrResourceConflict, "CreateCloudlet", cloudlet.Key, region,
"cloudlet may already exist or have conflicting configuration")
}
return domain.NewCloudletError(domain.ErrInternalError, "CreateCloudlet", cloudlet.Key, region,
"failed to create cloudlet").WithDetails(err.Error())
}
return nil
}
func (s *service) ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) {
if err := s.validateCloudletKey(cloudletKey); err != nil {
return nil, err
}
if region == "" {
return nil, domain.ErrMissingRegion
}
cloudlet, err := s.cloudletRepo.ShowCloudlet(ctx, region, cloudletKey)
if err != nil {
if domain.IsNotFoundError(err) {
return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region,
"cloudlet does not exist")
}
return nil, domain.NewCloudletError(domain.ErrInternalError, "ShowCloudlet", cloudletKey, region,
"failed to retrieve cloudlet").WithDetails(err.Error())
}
return cloudlet, nil
}
func (s *service) ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) {
if region == "" {
return nil, domain.ErrMissingRegion
}
cloudlets, err := s.cloudletRepo.ShowCloudlets(ctx, region, cloudletKey)
if err != nil {
return nil, domain.NewCloudletError(domain.ErrInternalError, "ShowCloudlets", cloudletKey, region,
"failed to list cloudlets").WithDetails(err.Error())
}
return cloudlets, nil
}
func (s *service) DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error {
if err := s.validateCloudletKey(cloudletKey); err != nil {
return err
}
if region == "" {
return domain.ErrMissingRegion
}
if err := s.cloudletRepo.DeleteCloudlet(ctx, region, cloudletKey); err != nil {
if domain.IsNotFoundError(err) {
return domain.NewCloudletError(domain.ErrResourceNotFound, "DeleteCloudlet", cloudletKey, region,
"cloudlet does not exist")
}
return domain.NewCloudletError(domain.ErrInternalError, "DeleteCloudlet", cloudletKey, region,
"failed to delete cloudlet").WithDetails(err.Error())
}
return nil
}
// validateCloudlet performs business logic validation on a cloudlet
func (s *service) validateCloudlet(cloudlet *domain.Cloudlet) error {
if cloudlet == nil {
return domain.NewDomainError(domain.ErrValidationFailed, "cloudlet cannot be nil")
}
return s.validateCloudletKey(cloudlet.Key)
}
// validateCloudletKey performs business logic validation on a cloudlet key
func (s *service) validateCloudletKey(cloudletKey domain.CloudletKey) error {
if strings.TrimSpace(cloudletKey.Organization) == "" {
return domain.ErrInvalidCloudletKey.WithDetails("organization is required")
}
if strings.TrimSpace(cloudletKey.Name) == "" {
return domain.ErrInvalidCloudletKey.WithDetails("name is required")
}
return nil
}

View file

@ -0,0 +1,185 @@
package instance
import (
"context"
"strings"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving"
)
type service struct {
appInstanceRepo driven.AppInstanceRepository
}
func NewService(appInstanceRepo driven.AppInstanceRepository) driving.AppInstanceService {
return &service{appInstanceRepo: appInstanceRepo}
}
func (s *service) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
if err := s.validateAppInstance(appInst); err != nil {
return err
}
if region == "" {
return domain.ErrMissingRegion
}
if err := s.appInstanceRepo.CreateAppInstance(ctx, region, appInst); err != nil {
if domain.IsNotFoundError(err) {
return domain.NewInstanceError(domain.ErrResourceConflict, "CreateAppInstance", appInst.Key, region,
"app instance may already exist or have conflicting configuration")
}
return domain.NewInstanceError(domain.ErrInternalError, "CreateAppInstance", appInst.Key, region,
"failed to create app instance").WithDetails(err.Error())
}
return nil
}
func (s *service) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) {
if err := s.validateAppInstanceKey(appInstKey); err != nil {
return nil, err
}
if region == "" {
return nil, domain.ErrMissingRegion
}
instance, err := s.appInstanceRepo.ShowAppInstance(ctx, region, appInstKey)
if err != nil {
if domain.IsNotFoundError(err) {
return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region,
"app instance does not exist")
}
return nil, domain.NewInstanceError(domain.ErrInternalError, "ShowAppInstance", appInstKey, region,
"failed to retrieve app instance").WithDetails(err.Error())
}
return instance, nil
}
func (s *service) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) {
if region == "" {
return nil, domain.ErrMissingRegion
}
instances, err := s.appInstanceRepo.ShowAppInstances(ctx, region, appInstKey)
if err != nil {
return nil, domain.NewInstanceError(domain.ErrInternalError, "ShowAppInstances", appInstKey, region,
"failed to list app instances").WithDetails(err.Error())
}
return instances, nil
}
func (s *service) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
if err := s.validateAppInstanceKey(appInstKey); err != nil {
return err
}
if region == "" {
return domain.ErrMissingRegion
}
if err := s.appInstanceRepo.DeleteAppInstance(ctx, region, appInstKey); err != nil {
if domain.IsNotFoundError(err) {
return domain.NewInstanceError(domain.ErrResourceNotFound, "DeleteAppInstance", appInstKey, region,
"app instance does not exist")
}
return domain.NewInstanceError(domain.ErrInternalError, "DeleteAppInstance", appInstKey, region,
"failed to delete app instance").WithDetails(err.Error())
}
return nil
}
func (s *service) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
if err := s.validateAppInstance(appInst); err != nil {
return err
}
if region == "" {
return domain.ErrMissingRegion
}
if err := s.appInstanceRepo.UpdateAppInstance(ctx, region, appInst); err != nil {
if domain.IsNotFoundError(err) {
return domain.NewInstanceError(domain.ErrResourceConflict, "UpdateAppInstance", appInst.Key, region,
"app instance may already exist or have conflicting configuration")
}
return domain.NewInstanceError(domain.ErrInternalError, "UpdateAppInstance", appInst.Key, region,
"failed to update app instance").WithDetails(err.Error())
}
return nil
}
func (s *service) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
if err := s.validateAppInstanceKey(appInstKey); err != nil {
return err
}
if region == "" {
return domain.ErrMissingRegion
}
// Note: The driven port (repository) does not currently have a Refresh method.
// This is a placeholder implementation.
// To fully implement this, we would need to add RefreshAppInstance to the AppInstanceRepository interface
// and implement it in the edgeconnect adapter.
// For now, we can just return nil or a 'not implemented' error.
// Let's delegate to the Show method as a temporary measure.
_, err := s.appInstanceRepo.ShowAppInstance(ctx, region, appInstKey)
if err != nil {
if domain.IsNotFoundError(err) {
return domain.NewInstanceError(domain.ErrResourceNotFound, "RefreshAppInstance", appInstKey, region,
"app instance does not exist")
}
return domain.NewInstanceError(domain.ErrInternalError, "RefreshAppInstance", appInstKey, region,
"failed to refresh app instance").WithDetails(err.Error())
}
return nil
}
// validateAppInstance performs business logic validation on an app instance
func (s *service) validateAppInstance(appInst *domain.AppInstance) error {
if appInst == nil {
return domain.NewDomainError(domain.ErrValidationFailed, "app instance cannot be nil")
}
if err := s.validateAppInstanceKey(appInst.Key); err != nil {
return err
}
// Validate flavor if present
if strings.TrimSpace(appInst.Flavor.Name) == "" {
return domain.NewDomainError(domain.ErrValidationFailed, "flavor name is required")
}
return nil
}
// validateAppInstanceKey performs business logic validation on an app instance key
func (s *service) validateAppInstanceKey(appInstKey domain.AppInstanceKey) error {
if strings.TrimSpace(appInstKey.Organization) == "" {
return domain.ErrInvalidInstanceKey.WithDetails("organization is required")
}
if strings.TrimSpace(appInstKey.Name) == "" {
return domain.ErrInvalidInstanceKey.WithDetails("name is required")
}
// Validate embedded cloudlet key
if strings.TrimSpace(appInstKey.CloudletKey.Organization) == "" {
return domain.ErrInvalidInstanceKey.WithDetails("cloudlet organization is required")
}
if strings.TrimSpace(appInstKey.CloudletKey.Name) == "" {
return domain.ErrInvalidInstanceKey.WithDetails("cloudlet name is required")
}
return nil
}

View file

@ -0,0 +1,63 @@
package organization
import (
"context"
"strings"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving"
)
// service implements the OrganizationService interface and provides the core business logic.
type service struct {
repo driven.OrganizationRepository
}
// NewService creates a new organization service with the given repository.
func NewService(repo driven.OrganizationRepository) driving.OrganizationService {
return &service{repo: repo}
}
// CreateOrganization validates the organization and passes it to the repository for creation.
func (s *service) Create(ctx context.Context, org *domain.Organization) error {
if err := s.validateOrganization(org); err != nil {
return err
}
return s.repo.CreateOrganization(ctx, org)
}
// Get retrieves an organization by name.
func (s *service) Get(ctx context.Context, name string) (*domain.Organization, error) {
if strings.TrimSpace(name) == "" {
return nil, domain.NewDomainError(domain.ErrValidationFailed, "organization name cannot be empty")
}
return s.repo.ShowOrganization(ctx, name)
}
// Update validates the organization and passes it to the repository for updates.
func (s *service) Update(ctx context.Context, org *domain.Organization) error {
if err := s.validateOrganization(org); err != nil {
return err
}
return s.repo.UpdateOrganization(ctx, org)
}
// Delete removes an organization by name.
func (s *service) Delete(ctx context.Context, name string) error {
if strings.TrimSpace(name) == "" {
return domain.NewDomainError(domain.ErrValidationFailed, "organization name cannot be empty")
}
return s.repo.DeleteOrganization(ctx, name)
}
// validateOrganization contains the business logic for validating an organization.
func (s *service) validateOrganization(org *domain.Organization) error {
if org == nil {
return domain.NewDomainError(domain.ErrValidationFailed, "organization cannot be nil")
}
if strings.TrimSpace(org.Name) == "" {
return domain.NewDomainError(domain.ErrValidationFailed, "organization name cannot be empty")
}
return nil
}

View file

@ -1,557 +0,0 @@
// ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison
// ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls
package v1
import (
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
)
// EdgeConnectClientInterface defines the methods needed for deployment planning
type EdgeConnectClientInterface interface {
ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error)
CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error
UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error
DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error
ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error)
CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error
UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error
}
// Planner defines the interface for deployment planning
type Planner interface {
// Plan analyzes the configuration and current state to generate a deployment plan
Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error)
// PlanWithOptions allows customization of planning behavior
PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error)
}
// PlanOptions provides configuration for the planning process
type PlanOptions struct {
// DryRun indicates this is a planning-only operation
DryRun bool
// Force indicates to proceed even with warnings
Force bool
// SkipStateCheck bypasses current state queries (useful for testing)
SkipStateCheck bool
// ParallelQueries enables parallel state fetching
ParallelQueries bool
// Timeout for API operations
Timeout time.Duration
}
// DefaultPlanOptions returns sensible default planning options
func DefaultPlanOptions() PlanOptions {
return PlanOptions{
DryRun: false,
Force: false,
SkipStateCheck: false,
ParallelQueries: true,
Timeout: 30 * time.Second,
}
}
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
type EdgeConnectPlanner struct {
client EdgeConnectClientInterface
}
// NewPlanner creates a new EdgeConnect deployment planner
func NewPlanner(client EdgeConnectClientInterface) Planner {
return &EdgeConnectPlanner{
client: client,
}
}
// Plan analyzes the configuration and generates a deployment plan
func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) {
return p.PlanWithOptions(ctx, config, DefaultPlanOptions())
}
// PlanWithOptions generates a deployment plan with custom options
func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) {
startTime := time.Now()
var warnings []string
// Create the deployment plan structure
plan := &DeploymentPlan{
ConfigName: config.Metadata.Name,
CreatedAt: startTime,
DryRun: opts.DryRun,
}
// Step 1: Plan application state
appAction, appWarnings, err := p.planAppAction(ctx, config, opts)
if err != nil {
return &PlanResult{Error: err}, err
}
plan.AppAction = *appAction
warnings = append(warnings, appWarnings...)
// Step 2: Plan instance actions
instanceActions, instanceWarnings, err := p.planInstanceActions(ctx, config, opts)
if err != nil {
return &PlanResult{Error: err}, err
}
plan.InstanceActions = instanceActions
warnings = append(warnings, instanceWarnings...)
// Step 3: Calculate plan metadata
p.calculatePlanMetadata(plan)
// Step 4: Generate summary
plan.Summary = plan.GenerateSummary()
// Step 5: Validate the plan
if err := plan.Validate(); err != nil {
return &PlanResult{Error: fmt.Errorf("invalid deployment plan: %w", err)}, err
}
return &PlanResult{
Plan: plan,
Warnings: warnings,
}, nil
}
// planAppAction determines what action needs to be taken for the application
func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*AppAction, []string, error) {
var warnings []string
// Build desired app state
desired := &AppState{
Name: config.Metadata.Name,
Version: config.Metadata.AppVersion,
Organization: config.Metadata.Organization, // Use first infra template for org
Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region
Exists: false, // Will be set based on current state
}
if config.Spec.IsK8sApp() {
desired.AppType = AppTypeK8s
} else {
desired.AppType = AppTypeDocker
}
// Extract outbound connections from config
if config.Spec.Network != nil {
desired.OutboundConnections = make([]SecurityRule, len(config.Spec.Network.OutboundConnections))
for i, conn := range config.Spec.Network.OutboundConnections {
desired.OutboundConnections[i] = SecurityRule{
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
}
}
// Calculate manifest hash
manifestHash, err := p.calculateManifestHash(config.Spec.GetManifestFile())
if err != nil {
return nil, warnings, fmt.Errorf("failed to calculate manifest hash: %w", err)
}
desired.ManifestHash = manifestHash
action := &AppAction{
Type: ActionNone,
Desired: desired,
ManifestHash: manifestHash,
Reason: "No action needed",
}
// Skip state check if requested (useful for testing)
if opts.SkipStateCheck {
action.Type = ActionCreate
action.Reason = "Creating app (state check skipped)"
action.Changes = []string{"Create new application"}
return action, warnings, nil
}
// Query current app state
current, err := p.getCurrentAppState(ctx, desired, opts.Timeout)
if err != nil {
// If app doesn't exist, we need to create it
if isResourceNotFoundError(err) {
action.Type = ActionCreate
action.Reason = "Application does not exist"
action.Changes = []string{"Create new application"}
return action, warnings, nil
}
return nil, warnings, fmt.Errorf("failed to query current app state: %w", err)
}
action.Current = current
// Compare current vs desired state
changes, manifestChanged := p.compareAppStates(current, desired)
action.ManifestChanged = manifestChanged
if len(changes) > 0 {
action.Type = ActionUpdate
action.Changes = changes
action.Reason = "Application configuration has changed"
fmt.Printf("Changes: %v\n", changes)
if manifestChanged {
warnings = append(warnings, "Manifest file has changed - instances may need to be recreated")
}
}
return action, warnings, nil
}
// planInstanceActions determines what actions need to be taken for instances
func (p *EdgeConnectPlanner) planInstanceActions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) ([]InstanceAction, []string, error) {
var actions []InstanceAction
var warnings []string
for _, infra := range config.Spec.InfraTemplate {
instanceName := getInstanceName(config.Metadata.Name, config.Metadata.AppVersion)
desired := &InstanceState{
Name: instanceName,
AppVersion: config.Metadata.AppVersion,
Organization: config.Metadata.Organization,
Region: infra.Region,
CloudletOrg: infra.CloudletOrg,
CloudletName: infra.CloudletName,
FlavorName: infra.FlavorName,
Exists: false,
}
action := &InstanceAction{
Type: ActionNone,
Target: infra,
Desired: desired,
InstanceName: instanceName,
Reason: "No action needed",
}
// Skip state check if requested
if opts.SkipStateCheck {
action.Type = ActionCreate
action.Reason = "Creating instance (state check skipped)"
action.Changes = []string{"Create new instance"}
actions = append(actions, *action)
continue
}
// Query current instance state
current, err := p.getCurrentInstanceState(ctx, desired, opts.Timeout)
if err != nil {
// If instance doesn't exist, we need to create it
if isResourceNotFoundError(err) {
action.Type = ActionCreate
action.Reason = "Instance does not exist"
action.Changes = []string{"Create new instance"}
actions = append(actions, *action)
continue
}
return nil, warnings, fmt.Errorf("failed to query current instance state: %w", err)
}
action.Current = current
// Compare current vs desired state
changes := p.compareInstanceStates(current, desired)
if len(changes) > 0 {
action.Type = ActionUpdate
action.Changes = changes
action.Reason = "Instance configuration has changed"
}
actions = append(actions, *action)
}
return actions, warnings, nil
}
// getCurrentAppState queries the current state of an application
func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *AppState, timeout time.Duration) (*AppState, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
appKey := edgeconnect.AppKey{
Organization: desired.Organization,
Name: desired.Name,
Version: desired.Version,
}
app, err := p.client.ShowApp(timeoutCtx, appKey, desired.Region)
if err != nil {
return nil, err
}
current := &AppState{
Name: app.Key.Name,
Version: app.Key.Version,
Organization: app.Key.Organization,
Region: desired.Region,
Exists: true,
LastUpdated: time.Now(), // EdgeConnect doesn't provide this, so use current time
}
// Calculate current manifest hash
hasher := sha256.New()
hasher.Write([]byte(app.DeploymentManifest))
current.ManifestHash = fmt.Sprintf("%x", hasher.Sum(nil))
// Note: EdgeConnect API doesn't currently support annotations for manifest hash tracking
// This would be implemented when the API supports it
// Determine app type based on deployment type
if app.Deployment == "kubernetes" {
current.AppType = AppTypeK8s
} else {
current.AppType = AppTypeDocker
}
// Extract outbound connections from the app
current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections))
for i, conn := range app.RequiredOutboundConnections {
current.OutboundConnections[i] = SecurityRule(conn)
}
return current, nil
}
// getCurrentInstanceState queries the current state of an application instance
func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desired *InstanceState, timeout time.Duration) (*InstanceState, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
instanceKey := edgeconnect.AppInstanceKey{
Organization: desired.Organization,
Name: desired.Name,
CloudletKey: edgeconnect.CloudletKey{
Organization: desired.CloudletOrg,
Name: desired.CloudletName,
},
}
appKey := edgeconnect.AppKey{
Name: desired.AppName,
}
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region)
if err != nil {
return nil, err
}
current := &InstanceState{
Name: instance.Key.Name,
AppName: instance.AppKey.Name,
AppVersion: instance.AppKey.Version,
Organization: instance.Key.Organization,
Region: desired.Region,
CloudletOrg: instance.Key.CloudletKey.Organization,
CloudletName: instance.Key.CloudletKey.Name,
FlavorName: instance.Flavor.Name,
State: instance.State,
PowerState: instance.PowerState,
Exists: true,
LastUpdated: time.Now(), // EdgeConnect doesn't provide this
}
return current, nil
}
// compareAppStates compares current and desired app states and returns changes
func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]string, bool) {
var changes []string
manifestChanged := false
// Compare manifest hash - only if both states have hash values
// Since EdgeConnect API doesn't support annotations yet, skip manifest hash comparison for now
// This would be implemented when the API supports manifest hash tracking
if current.ManifestHash != "" && desired.ManifestHash != "" && current.ManifestHash != desired.ManifestHash {
changes = append(changes, fmt.Sprintf("Manifest hash changed: %s -> %s", current.ManifestHash, desired.ManifestHash))
manifestChanged = true
}
// Compare app type
if current.AppType != desired.AppType {
changes = append(changes, fmt.Sprintf("App type changed: %s -> %s", current.AppType, desired.AppType))
}
// Compare outbound connections
outboundChanges := p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections)
if len(outboundChanges) > 0 {
sb := strings.Builder{}
sb.WriteString("Outbound connections changed:\n")
for _, change := range outboundChanges {
sb.WriteString(change)
sb.WriteString("\n")
}
changes = append(changes, sb.String())
}
return changes, manifestChanged
}
// compareOutboundConnections compares two sets of outbound connections for equality
func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) []string {
var changes []string
makeMap := func(rules []SecurityRule) map[string]SecurityRule {
m := make(map[string]SecurityRule, len(rules))
for _, r := range rules {
key := fmt.Sprintf("%s:%d-%d:%s",
strings.ToLower(r.Protocol),
r.PortRangeMin,
r.PortRangeMax,
r.RemoteCIDR,
)
m[key] = r
}
return m
}
currentMap := makeMap(current)
desiredMap := makeMap(desired)
// Find added and modified rules
for key, rule := range desiredMap {
if _, exists := currentMap[key]; !exists {
changes = append(changes, fmt.Sprintf(" - Added outbound connection: %s %d-%d to %s", rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteCIDR))
}
}
// Find removed rules
for key, rule := range currentMap {
if _, exists := desiredMap[key]; !exists {
changes = append(changes, fmt.Sprintf(" - Removed outbound connection: %s %d-%d to %s", rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteCIDR))
}
}
return changes
}
// compareInstanceStates compares current and desired instance states and returns changes
func (p *EdgeConnectPlanner) compareInstanceStates(current, desired *InstanceState) []string {
var changes []string
if current.FlavorName != desired.FlavorName {
changes = append(changes, fmt.Sprintf("Flavor changed: %s -> %s", current.FlavorName, desired.FlavorName))
}
if current.CloudletName != desired.CloudletName {
changes = append(changes, fmt.Sprintf("Cloudlet changed: %s -> %s", current.CloudletName, desired.CloudletName))
}
if current.CloudletOrg != desired.CloudletOrg {
changes = append(changes, fmt.Sprintf("Cloudlet org changed: %s -> %s", current.CloudletOrg, desired.CloudletOrg))
}
return changes
}
// calculateManifestHash computes the SHA256 hash of a manifest file
func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string, error) {
if manifestPath == "" {
return "", nil
}
file, err := os.Open(manifestPath)
if err != nil {
return "", fmt.Errorf("failed to open manifest file: %w", err)
}
defer func() {
_ = file.Close()
}()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return "", fmt.Errorf("failed to hash manifest file: %w", err)
}
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
}
// calculatePlanMetadata computes metadata for the deployment plan
func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeploymentPlan) {
totalActions := 0
if plan.AppAction.Type != ActionNone {
totalActions++
}
for _, action := range plan.InstanceActions {
if action.Type != ActionNone {
totalActions++
}
}
plan.TotalActions = totalActions
// Estimate duration based on action types and counts
plan.EstimatedDuration = p.estimateDeploymentDuration(plan)
}
// estimateDeploymentDuration provides a rough estimate of deployment time
func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) time.Duration {
var duration time.Duration
// App operations
switch plan.AppAction.Type {
case ActionCreate:
duration += 30 * time.Second
case ActionUpdate:
duration += 15 * time.Second
}
// Instance operations (can be done in parallel)
instanceDuration := time.Duration(0)
for _, action := range plan.InstanceActions {
switch action.Type {
case ActionCreate:
instanceDuration = max(instanceDuration, 2*time.Minute)
case ActionUpdate:
instanceDuration = max(instanceDuration, 1*time.Minute)
}
}
duration += instanceDuration
// Add buffer time
duration += 30 * time.Second
return duration
}
// isResourceNotFoundError checks if an error indicates a resource was not found
func isResourceNotFoundError(err error) bool {
if err == nil {
return false
}
errStr := strings.ToLower(err.Error())
return strings.Contains(errStr, "not found") ||
strings.Contains(errStr, "does not exist") ||
strings.Contains(errStr, "404")
}
// max returns the larger of two durations
func max(a, b time.Duration) time.Duration {
if a > b {
return a
}
return b
}
// getInstanceName generates the instance name following the pattern: appName-appVersion-instance
func getInstanceName(appName, appVersion string) string {
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
}

View file

@ -1,655 +0,0 @@
// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios
// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios
package v1
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// MockEdgeConnectClient is a mock implementation of the EdgeConnect client
type MockEdgeConnectClient struct {
mock.Mock
}
func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) {
args := m.Called(ctx, appKey, region)
if args.Get(0) == nil {
return edgeconnect.App{}, args.Error(1)
}
return args.Get(0).(edgeconnect.App), args.Error(1)
}
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error) {
args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil {
return edgeconnect.AppInstance{}, args.Error(1)
}
return args.Get(0).(edgeconnect.AppInstance), args.Error(1)
}
func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error {
args := m.Called(ctx, appKey, region)
return args.Error(0)
}
func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error {
args := m.Called(ctx, instanceKey, region)
return args.Error(0)
}
func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect.AppKey, region string) ([]edgeconnect.App, error) {
args := m.Called(ctx, appKey, region)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]edgeconnect.App), args.Error(1)
}
func TestNewPlanner(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
assert.NotNil(t, planner)
assert.IsType(t, &EdgeConnectPlanner{}, planner)
}
func TestDefaultPlanOptions(t *testing.T) {
opts := DefaultPlanOptions()
assert.False(t, opts.DryRun)
assert.False(t, opts.Force)
assert.False(t, opts.SkipStateCheck)
assert.True(t, opts.ParallelQueries)
assert.Equal(t, 30*time.Second, opts.Timeout)
}
func createTestConfig(t *testing.T) *config.EdgeConnectConfig {
// Create temporary manifest file
tempDir := t.TempDir()
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
require.NoError(t, err)
return &config.EdgeConnectConfig{
Kind: "edgeconnect-deployment",
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "testorg",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{
ManifestFile: manifestFile,
},
InfraTemplate: []config.InfraTemplate{
{
Region: "US",
CloudletOrg: "TestCloudletOrg",
CloudletName: "TestCloudlet",
FlavorName: "small",
},
},
Network: &config.NetworkConfig{
OutboundConnections: []config.OutboundConnection{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
},
},
}
}
func TestPlanNewDeployment(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Mock API calls to return "not found" errors
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
require.NoError(t, result.Error)
plan := result.Plan
assert.Equal(t, "test-app", plan.ConfigName)
assert.Equal(t, ActionCreate, plan.AppAction.Type)
assert.Equal(t, "Application does not exist", plan.AppAction.Reason)
require.Len(t, plan.InstanceActions, 1)
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
assert.Equal(t, "Instance does not exist", plan.InstanceActions[0].Reason)
assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance
assert.False(t, plan.IsEmpty())
mockClient.AssertExpectations(t)
}
func TestPlanExistingDeploymentNoChanges(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Note: We would calculate expected manifest hash here when API supports it
// Mock existing app with same manifest hash and outbound connections
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
existingApp := &edgeconnect.App{
Key: edgeconnect.AppKey{
Organization: "testorg",
Name: "test-app",
Version: "1.0.0",
},
Deployment: "kubernetes",
DeploymentManifest: manifestContent,
RequiredOutboundConnections: []edgeconnect.SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
// Note: Manifest hash tracking would be implemented when API supports annotations
}
// Mock existing instance
existingInstance := &edgeconnect.AppInstance{
Key: edgeconnect.AppInstanceKey{
Organization: "testorg",
Name: "test-app-1.0.0-instance",
CloudletKey: edgeconnect.CloudletKey{
Organization: "TestCloudletOrg",
Name: "TestCloudlet",
},
},
AppKey: edgeconnect.AppKey{
Organization: "testorg",
Name: "test-app",
Version: "1.0.0",
},
Flavor: edgeconnect.Flavor{
Name: "small",
},
State: "Ready",
PowerState: "PowerOn",
}
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
Return(*existingApp, nil)
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US").
Return(*existingInstance, nil)
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.Equal(t, ActionNone, plan.AppAction.Type)
assert.Len(t, plan.InstanceActions, 1)
assert.Equal(t, ActionNone, plan.InstanceActions[0].Type)
assert.Equal(t, 0, plan.TotalActions)
assert.True(t, plan.IsEmpty())
assert.Contains(t, plan.Summary, "No changes required")
mockClient.AssertExpectations(t)
}
func TestPlanWithOptions(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
opts := PlanOptions{
DryRun: true,
SkipStateCheck: true,
Timeout: 10 * time.Second,
}
ctx := context.Background()
result, err := planner.PlanWithOptions(ctx, testConfig, opts)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.True(t, plan.DryRun)
assert.Equal(t, ActionCreate, plan.AppAction.Type)
assert.Contains(t, plan.AppAction.Reason, "state check skipped")
// No API calls should be made when SkipStateCheck is true
mockClient.AssertNotCalled(t, "ShowApp")
mockClient.AssertNotCalled(t, "ShowAppInstance")
}
func TestPlanMultipleInfrastructures(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Add a second infrastructure target
testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{
Region: "EU",
CloudletOrg: "EUCloudletOrg",
CloudletName: "EUCloudlet",
FlavorName: "medium",
})
// Mock API calls to return "not found" errors
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "EU").
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.Equal(t, ActionCreate, plan.AppAction.Type)
// Should have 2 instance actions, one for each infrastructure
require.Len(t, plan.InstanceActions, 2)
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
assert.Equal(t, ActionCreate, plan.InstanceActions[1].Type)
assert.Equal(t, 3, plan.TotalActions) // 1 app + 2 instances
// Test cloudlet and region aggregation
cloudlets := plan.GetTargetCloudlets()
regions := plan.GetTargetRegions()
assert.Len(t, cloudlets, 2)
assert.Len(t, regions, 2)
mockClient.AssertExpectations(t)
}
func TestCalculateManifestHash(t *testing.T) {
planner := &EdgeConnectPlanner{}
tempDir := t.TempDir()
// Create test file
testFile := filepath.Join(tempDir, "test.yaml")
content := "test content for hashing"
err := os.WriteFile(testFile, []byte(content), 0644)
require.NoError(t, err)
hash1, err := planner.calculateManifestHash(testFile)
require.NoError(t, err)
assert.NotEmpty(t, hash1)
assert.Len(t, hash1, 64) // SHA256 hex string length
// Same content should produce same hash
hash2, err := planner.calculateManifestHash(testFile)
require.NoError(t, err)
assert.Equal(t, hash1, hash2)
// Different content should produce different hash
err = os.WriteFile(testFile, []byte("different content"), 0644)
require.NoError(t, err)
hash3, err := planner.calculateManifestHash(testFile)
require.NoError(t, err)
assert.NotEqual(t, hash1, hash3)
// Empty file path should return empty hash
hash4, err := planner.calculateManifestHash("")
require.NoError(t, err)
assert.Empty(t, hash4)
// Non-existent file should return error
_, err = planner.calculateManifestHash("/non/existent/file")
assert.Error(t, err)
}
func TestCompareAppStates(t *testing.T) {
planner := &EdgeConnectPlanner{}
current := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
ManifestHash: "old-hash",
}
desired := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
ManifestHash: "new-hash",
}
changes, manifestChanged := planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.True(t, manifestChanged)
assert.Contains(t, changes[0], "Manifest hash changed")
// Test no changes
desired.ManifestHash = "old-hash"
changes, manifestChanged = planner.compareAppStates(current, desired)
assert.Empty(t, changes)
assert.False(t, manifestChanged)
// Test app type change
desired.AppType = AppTypeDocker
changes, manifestChanged = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.False(t, manifestChanged)
assert.Contains(t, changes[0], "App type changed")
}
func TestCompareAppStatesOutboundConnections(t *testing.T) {
planner := &EdgeConnectPlanner{}
// Test with no outbound connections
current := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
OutboundConnections: nil,
}
desired := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
OutboundConnections: nil,
}
changes, _ := planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when both have no outbound connections")
// Test adding outbound connections
desired.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
// Test identical outbound connections
current.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when outbound connections are identical")
// Test different outbound connections (different port)
desired.OutboundConnections[0].PortRangeMin = 443
desired.OutboundConnections[0].PortRangeMax = 443
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
// Test same connections but different order (should be considered equal)
current.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 443,
PortRangeMax: 443,
RemoteCIDR: "0.0.0.0/0",
},
}
desired.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 443,
PortRangeMax: 443,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order")
// Test removing outbound connections
desired.OutboundConnections = nil
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
}
func TestCompareInstanceStates(t *testing.T) {
planner := &EdgeConnectPlanner{}
current := &InstanceState{
Name: "test-instance",
FlavorName: "small",
CloudletName: "oldcloudlet",
CloudletOrg: "oldorg",
}
desired := &InstanceState{
Name: "test-instance",
FlavorName: "medium",
CloudletName: "newcloudlet",
CloudletOrg: "neworg",
}
changes := planner.compareInstanceStates(current, desired)
assert.Len(t, changes, 3)
assert.Contains(t, changes[0], "Flavor changed")
assert.Contains(t, changes[1], "Cloudlet changed")
assert.Contains(t, changes[2], "Cloudlet org changed")
// Test no changes
desired.FlavorName = "small"
desired.CloudletName = "oldcloudlet"
desired.CloudletOrg = "oldorg"
changes = planner.compareInstanceStates(current, desired)
assert.Empty(t, changes)
}
func TestDeploymentPlanMethods(t *testing.T) {
plan := &DeploymentPlan{
ConfigName: "test-plan",
AppAction: AppAction{
Type: ActionCreate,
Desired: &AppState{Name: "test-app"},
},
InstanceActions: []InstanceAction{
{
Type: ActionCreate,
Target: config.InfraTemplate{
CloudletOrg: "org1",
CloudletName: "cloudlet1",
Region: "US",
},
InstanceName: "instance1",
Desired: &InstanceState{Name: "instance1"},
},
{
Type: ActionUpdate,
Target: config.InfraTemplate{
CloudletOrg: "org2",
CloudletName: "cloudlet2",
Region: "EU",
},
InstanceName: "instance2",
Desired: &InstanceState{Name: "instance2"},
},
},
}
// Test IsEmpty
assert.False(t, plan.IsEmpty())
// Test GetTargetCloudlets
cloudlets := plan.GetTargetCloudlets()
assert.Len(t, cloudlets, 2)
assert.Contains(t, cloudlets, "org1:cloudlet1")
assert.Contains(t, cloudlets, "org2:cloudlet2")
// Test GetTargetRegions
regions := plan.GetTargetRegions()
assert.Len(t, regions, 2)
assert.Contains(t, regions, "US")
assert.Contains(t, regions, "EU")
// Test GenerateSummary
summary := plan.GenerateSummary()
assert.Contains(t, summary, "test-plan")
assert.Contains(t, summary, "CREATE application")
assert.Contains(t, summary, "CREATE 1 instance")
assert.Contains(t, summary, "UPDATE 1 instance")
// Test Validate
err := plan.Validate()
assert.NoError(t, err)
// Test validation failure
plan.AppAction.Desired = nil
err = plan.Validate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "must have desired state")
}
func TestEstimateDeploymentDuration(t *testing.T) {
planner := &EdgeConnectPlanner{}
plan := &DeploymentPlan{
AppAction: AppAction{Type: ActionCreate},
InstanceActions: []InstanceAction{
{Type: ActionCreate},
{Type: ActionUpdate},
},
}
duration := planner.estimateDeploymentDuration(plan)
assert.Greater(t, duration, time.Duration(0))
assert.Less(t, duration, 10*time.Minute) // Reasonable upper bound
// Test with no actions
emptyPlan := &DeploymentPlan{
AppAction: AppAction{Type: ActionNone},
InstanceActions: []InstanceAction{},
}
emptyDuration := planner.estimateDeploymentDuration(emptyPlan)
assert.Greater(t, emptyDuration, time.Duration(0))
assert.Less(t, emptyDuration, duration) // Should be less than plan with actions
}
func TestIsResourceNotFoundError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"nil error", nil, false},
{"not found error", &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true},
{"does not exist error", &edgeconnect.APIError{Messages: []string{"App does not exist"}}, true},
{"404 in message", &edgeconnect.APIError{Messages: []string{"HTTP 404 error"}}, true},
{"other error", &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isResourceNotFoundError(tt.err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestPlanErrorHandling(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Mock API call to return a non-404 error
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
Return(nil, &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}})
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
assert.Error(t, err)
assert.NotNil(t, result)
assert.NotNil(t, result.Error)
assert.Contains(t, err.Error(), "failed to query current app state")
mockClient.AssertExpectations(t)
}

View file

@ -1,106 +0,0 @@
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
package v1
import (
"context"
"fmt"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
)
// DeploymentStrategy represents the type of deployment strategy
type DeploymentStrategy string
const (
// StrategyRecreate deletes all instances, updates app, then creates new instances
StrategyRecreate DeploymentStrategy = "recreate"
// StrategyBlueGreen creates new instances alongside old ones, then switches traffic (future)
StrategyBlueGreen DeploymentStrategy = "blue-green"
// StrategyRolling updates instances one by one with health checks (future)
StrategyRolling DeploymentStrategy = "rolling"
)
// DeploymentStrategyExecutor defines the interface that all deployment strategies must implement
type DeploymentStrategyExecutor interface {
// Execute runs the deployment strategy
Execute(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error)
// Validate checks if the strategy can be used for this deployment
Validate(plan *DeploymentPlan) error
// EstimateDuration provides time estimate for this strategy
EstimateDuration(plan *DeploymentPlan) time.Duration
// GetName returns the strategy name
GetName() DeploymentStrategy
}
// StrategyConfig holds configuration for deployment strategies
type StrategyConfig struct {
// MaxRetries is the number of times to retry failed operations
MaxRetries int
// HealthCheckTimeout is the maximum time to wait for health checks
HealthCheckTimeout time.Duration
// ParallelOperations enables parallel execution of operations
ParallelOperations bool
// RetryDelay is the delay between retry attempts
RetryDelay time.Duration
}
// DefaultStrategyConfig returns sensible defaults for strategy configuration
func DefaultStrategyConfig() StrategyConfig {
return StrategyConfig{
MaxRetries: 5, // Retry 5 times
HealthCheckTimeout: 5 * time.Minute, // Max 5 mins health check
ParallelOperations: true, // Parallel execution
RetryDelay: 10 * time.Second, // 10s between retries
}
}
// StrategyFactory creates deployment strategy executors
type StrategyFactory struct {
config StrategyConfig
client EdgeConnectClientInterface
logger Logger
}
// NewStrategyFactory creates a new strategy factory
func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *StrategyFactory {
return &StrategyFactory{
config: config,
client: client,
logger: logger,
}
}
// CreateStrategy creates the appropriate strategy executor based on the deployment strategy
func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) {
switch strategy {
case StrategyRecreate:
return NewRecreateStrategy(f.client, f.config, f.logger), nil
case StrategyBlueGreen:
// TODO: Implement blue-green strategy
return nil, fmt.Errorf("blue-green strategy not yet implemented")
case StrategyRolling:
// TODO: Implement rolling strategy
return nil, fmt.Errorf("rolling strategy not yet implemented")
default:
return nil, fmt.Errorf("unknown deployment strategy: %s", strategy)
}
}
// GetAvailableStrategies returns a list of all available strategies
func (f *StrategyFactory) GetAvailableStrategies() []DeploymentStrategy {
return []DeploymentStrategy{
StrategyRecreate,
// StrategyBlueGreen, // TODO: Enable when implemented
// StrategyRolling, // TODO: Enable when implemented
}
}

View file

@ -1,462 +0,0 @@
// ABOUTME: Deployment planning types for EdgeConnect apply command with state management
// ABOUTME: Defines structures for deployment plans, actions, and state comparison results
package v1
import (
"fmt"
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
)
// SecurityRule defines network access rules (alias to SDK type for consistency)
type SecurityRule = edgeconnect.SecurityRule
// ActionType represents the type of action to be performed
type ActionType string
const (
// ActionCreate indicates a resource needs to be created
ActionCreate ActionType = "CREATE"
// ActionUpdate indicates a resource needs to be updated
ActionUpdate ActionType = "UPDATE"
// ActionNone indicates no action is needed
ActionNone ActionType = "NONE"
// ActionDelete indicates a resource needs to be deleted (for rollback scenarios)
ActionDelete ActionType = "DELETE"
)
// String returns the string representation of ActionType
func (a ActionType) String() string {
return string(a)
}
// DeploymentPlan represents the complete deployment plan for a configuration
type DeploymentPlan struct {
// ConfigName is the name from metadata
ConfigName string
// AppAction defines what needs to be done with the application
AppAction AppAction
// InstanceActions defines what needs to be done with each instance
InstanceActions []InstanceAction
// Summary provides a human-readable summary of the plan
Summary string
// TotalActions is the count of all actions that will be performed
TotalActions int
// EstimatedDuration is the estimated time to complete the deployment
EstimatedDuration time.Duration
// CreatedAt timestamp when the plan was created
CreatedAt time.Time
// DryRun indicates if this is a dry-run plan
DryRun bool
}
// AppAction represents an action to be performed on an application
type AppAction struct {
// Type of action to perform
Type ActionType
// Current state of the app (nil if doesn't exist)
Current *AppState
// Desired state of the app
Desired *AppState
// Changes describes what will change
Changes []string
// Reason explains why this action is needed
Reason string
// ManifestHash is the hash of the current manifest file
ManifestHash string
// ManifestChanged indicates if the manifest content has changed
ManifestChanged bool
}
// InstanceAction represents an action to be performed on an application instance
type InstanceAction struct {
// Type of action to perform
Type ActionType
// Target infrastructure where the instance will be deployed
Target config.InfraTemplate
// Current state of the instance (nil if doesn't exist)
Current *InstanceState
// Desired state of the instance
Desired *InstanceState
// Changes describes what will change
Changes []string
// Reason explains why this action is needed
Reason string
// InstanceName is the generated name for this instance
InstanceName string
// Dependencies lists other instances this depends on
Dependencies []string
}
// AppState represents the current state of an application
type AppState struct {
// Name of the application
Name string
// Version of the application
Version string
// Organization that owns the app
Organization string
// Region where the app is deployed
Region string
// ManifestHash is the stored hash of the manifest file
ManifestHash string
// LastUpdated timestamp when the app was last modified
LastUpdated time.Time
// Exists indicates if the app currently exists
Exists bool
// AppType indicates whether this is a k8s or docker app
AppType AppType
// OutboundConnections contains the required outbound network connections
OutboundConnections []SecurityRule
}
// InstanceState represents the current state of an application instance
type InstanceState struct {
// Name of the instance
Name string
// AppName that this instance belongs to
AppName string
// AppVersion of the associated app
AppVersion string
// Organization that owns the instance
Organization string
// Region where the instance is deployed
Region string
// CloudletOrg that hosts the cloudlet
CloudletOrg string
// CloudletName where the instance is running
CloudletName string
// FlavorName used for the instance
FlavorName string
// State of the instance (e.g., "Ready", "Pending", "Error")
State string
// PowerState of the instance
PowerState string
// LastUpdated timestamp when the instance was last modified
LastUpdated time.Time
// Exists indicates if the instance currently exists
Exists bool
}
// AppType represents the type of application
type AppType string
const (
// AppTypeK8s represents a Kubernetes application
AppTypeK8s AppType = "k8s"
// AppTypeDocker represents a Docker application
AppTypeDocker AppType = "docker"
)
// String returns the string representation of AppType
func (a AppType) String() string {
return string(a)
}
// DeploymentSummary provides a high-level overview of the deployment plan
type DeploymentSummary struct {
// TotalActions is the total number of actions to be performed
TotalActions int
// ActionCounts breaks down actions by type
ActionCounts map[ActionType]int
// EstimatedDuration for the entire deployment
EstimatedDuration time.Duration
// ResourceSummary describes the resources involved
ResourceSummary ResourceSummary
// Warnings about potential issues
Warnings []string
}
// ResourceSummary provides details about resources in the deployment
type ResourceSummary struct {
// AppsToCreate number of apps that will be created
AppsToCreate int
// AppsToUpdate number of apps that will be updated
AppsToUpdate int
// InstancesToCreate number of instances that will be created
InstancesToCreate int
// InstancesToUpdate number of instances that will be updated
InstancesToUpdate int
// CloudletsAffected number of unique cloudlets involved
CloudletsAffected int
// RegionsAffected number of unique regions involved
RegionsAffected int
}
// PlanResult represents the result of a deployment planning operation
type PlanResult struct {
// Plan is the generated deployment plan
Plan *DeploymentPlan
// Error if planning failed
Error error
// Warnings encountered during planning
Warnings []string
}
// ExecutionResult represents the result of executing a deployment plan
type ExecutionResult struct {
// Plan that was executed
Plan *DeploymentPlan
// Success indicates if the deployment was successful
Success bool
// CompletedActions lists actions that were successfully completed
CompletedActions []ActionResult
// FailedActions lists actions that failed
FailedActions []ActionResult
// Error that caused the deployment to fail (if any)
Error error
// Duration taken to execute the plan
Duration time.Duration
// RollbackPerformed indicates if rollback was executed
RollbackPerformed bool
// RollbackSuccess indicates if rollback was successful
RollbackSuccess bool
}
// ActionResult represents the result of executing a single action
type ActionResult struct {
// Type of action that was attempted
Type ActionType
// Target describes what was being acted upon
Target string
// Success indicates if the action succeeded
Success bool
// Error if the action failed
Error error
// Duration taken to complete the action
Duration time.Duration
// Details provides additional information about the action
Details string
}
// IsEmpty returns true if the deployment plan has no actions to perform
func (dp *DeploymentPlan) IsEmpty() bool {
if dp.AppAction.Type != ActionNone {
return false
}
for _, action := range dp.InstanceActions {
if action.Type != ActionNone {
return false
}
}
return true
}
// HasErrors returns true if the plan contains any error conditions
func (dp *DeploymentPlan) HasErrors() bool {
// Check for conflicting actions or invalid states
return false // Implementation would check for various error conditions
}
// GetTargetCloudlets returns a list of unique cloudlets that will be affected
func (dp *DeploymentPlan) GetTargetCloudlets() []string {
cloudletSet := make(map[string]bool)
var cloudlets []string
for _, action := range dp.InstanceActions {
if action.Type != ActionNone {
key := fmt.Sprintf("%s:%s", action.Target.CloudletOrg, action.Target.CloudletName)
if !cloudletSet[key] {
cloudletSet[key] = true
cloudlets = append(cloudlets, key)
}
}
}
return cloudlets
}
// GetTargetRegions returns a list of unique regions that will be affected
func (dp *DeploymentPlan) GetTargetRegions() []string {
regionSet := make(map[string]bool)
var regions []string
for _, action := range dp.InstanceActions {
if action.Type != ActionNone && !regionSet[action.Target.Region] {
regionSet[action.Target.Region] = true
regions = append(regions, action.Target.Region)
}
}
return regions
}
// GenerateSummary creates a human-readable summary of the deployment plan
func (dp *DeploymentPlan) GenerateSummary() string {
if dp.IsEmpty() {
return "No changes required - configuration matches current state"
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName))
// App actions
if dp.AppAction.Type != ActionNone {
sb.WriteString(fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name))
if len(dp.AppAction.Changes) > 0 {
for _, change := range dp.AppAction.Changes {
sb.WriteString(fmt.Sprintf(" - %s\n", change))
}
}
}
// Instance actions
createCount := 0
updateActions := []InstanceAction{}
for _, action := range dp.InstanceActions {
switch action.Type {
case ActionCreate:
createCount++
case ActionUpdate:
updateActions = append(updateActions, action)
}
}
if createCount > 0 {
sb.WriteString(fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets())))
}
if len(updateActions) > 0 {
sb.WriteString(fmt.Sprintf("- UPDATE %d instance(s)\n", len(updateActions)))
for _, action := range updateActions {
if len(action.Changes) > 0 {
sb.WriteString(fmt.Sprintf(" - Instance '%s':\n", action.InstanceName))
for _, change := range action.Changes {
sb.WriteString(fmt.Sprintf(" - %s\n", change))
}
}
}
}
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
return sb.String()
}
// Validate checks if the deployment plan is valid and safe to execute
func (dp *DeploymentPlan) Validate() error {
if dp.ConfigName == "" {
return fmt.Errorf("deployment plan must have a config name")
}
// Validate app action
if dp.AppAction.Type != ActionNone && dp.AppAction.Desired == nil {
return fmt.Errorf("app action of type %s must have desired state", dp.AppAction.Type)
}
// Validate instance actions
for i, action := range dp.InstanceActions {
if action.Type != ActionNone {
if action.Desired == nil {
return fmt.Errorf("instance action %d of type %s must have desired state", i, action.Type)
}
if action.InstanceName == "" {
return fmt.Errorf("instance action %d must have an instance name", i)
}
}
}
return nil
}
// Clone creates a deep copy of the deployment plan
func (dp *DeploymentPlan) Clone() *DeploymentPlan {
clone := &DeploymentPlan{
ConfigName: dp.ConfigName,
Summary: dp.Summary,
TotalActions: dp.TotalActions,
EstimatedDuration: dp.EstimatedDuration,
CreatedAt: dp.CreatedAt,
DryRun: dp.DryRun,
AppAction: dp.AppAction, // Struct copy is sufficient for this use case
}
// Deep copy instance actions
clone.InstanceActions = make([]InstanceAction, len(dp.InstanceActions))
copy(clone.InstanceActions, dp.InstanceActions)
return clone
}
// convertNetworkRules converts config network rules to EdgeConnect SecurityRules
func convertNetworkRules(network *config.NetworkConfig) []edgeconnect.SecurityRule {
rules := make([]edgeconnect.SecurityRule, len(network.OutboundConnections))
for i, conn := range network.OutboundConnections {
rules[i] = edgeconnect.SecurityRule{
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
}
return rules
}

View file

@ -1,434 +0,0 @@
// ABOUTME: Resource management for EdgeConnect apply command with deployment execution and rollback
// ABOUTME: Handles actual deployment operations, manifest processing, and error recovery with parallel execution
package v2
import (
"context"
"errors"
"fmt"
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
)
// ResourceManagerInterface defines the interface for resource management
type ResourceManagerInterface interface {
// ApplyDeployment executes a deployment plan
ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error)
// RollbackDeployment attempts to rollback a failed deployment
RollbackDeployment(ctx context.Context, result *ExecutionResult) error
// ValidatePrerequisites checks if deployment prerequisites are met
ValidatePrerequisites(ctx context.Context, plan *DeploymentPlan) error
}
// EdgeConnectResourceManager implements resource management for EdgeConnect
type EdgeConnectResourceManager struct {
client EdgeConnectClientInterface
parallelLimit int
rollbackOnFail bool
logger Logger
strategyConfig StrategyConfig
}
// Logger interface for deployment logging
type Logger interface {
Printf(format string, v ...interface{})
}
// ResourceManagerOptions configures the resource manager behavior
type ResourceManagerOptions struct {
// ParallelLimit controls how many operations run concurrently
ParallelLimit int
// RollbackOnFail automatically rolls back on deployment failure
RollbackOnFail bool
// Logger for deployment operations
Logger Logger
// Timeout for individual operations
OperationTimeout time.Duration
// StrategyConfig for deployment strategies
StrategyConfig StrategyConfig
}
// DefaultResourceManagerOptions returns sensible defaults
func DefaultResourceManagerOptions() ResourceManagerOptions {
return ResourceManagerOptions{
ParallelLimit: 5, // Conservative parallel limit
RollbackOnFail: true,
OperationTimeout: 2 * time.Minute,
StrategyConfig: DefaultStrategyConfig(),
}
}
// NewResourceManager creates a new EdgeConnect resource manager
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
options := DefaultResourceManagerOptions()
for _, opt := range opts {
opt(&options)
}
return &EdgeConnectResourceManager{
client: client,
parallelLimit: options.ParallelLimit,
rollbackOnFail: options.RollbackOnFail,
logger: options.Logger,
strategyConfig: options.StrategyConfig,
}
}
// WithParallelLimit sets the parallel execution limit
func WithParallelLimit(limit int) func(*ResourceManagerOptions) {
return func(opts *ResourceManagerOptions) {
opts.ParallelLimit = limit
}
}
// WithRollbackOnFail enables/disables automatic rollback
func WithRollbackOnFail(rollback bool) func(*ResourceManagerOptions) {
return func(opts *ResourceManagerOptions) {
opts.RollbackOnFail = rollback
}
}
// WithLogger sets a logger for deployment operations
func WithLogger(logger Logger) func(*ResourceManagerOptions) {
return func(opts *ResourceManagerOptions) {
opts.Logger = logger
}
}
// WithStrategyConfig sets the strategy configuration
func WithStrategyConfig(config StrategyConfig) func(*ResourceManagerOptions) {
return func(opts *ResourceManagerOptions) {
opts.StrategyConfig = config
}
}
// ApplyDeployment executes a deployment plan using deployment strategies
func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) {
rm.logf("Starting deployment: %s", plan.ConfigName)
// Step 1: Validate prerequisites
if err := rm.ValidatePrerequisites(ctx, plan); err != nil {
result := &ExecutionResult{
Plan: plan,
CompletedActions: []ActionResult{},
FailedActions: []ActionResult{},
Error: fmt.Errorf("prerequisites validation failed: %w", err),
Duration: 0,
}
return result, err
}
// Step 2: Determine deployment strategy
strategyName := DeploymentStrategy(config.Spec.GetDeploymentStrategy())
rm.logf("Using deployment strategy: %s", strategyName)
// Step 3: Create strategy executor
strategyConfig := rm.strategyConfig
strategyConfig.ParallelOperations = rm.parallelLimit > 1
factory := NewStrategyFactory(rm.client, strategyConfig, rm.logger)
strategy, err := factory.CreateStrategy(strategyName)
if err != nil {
result := &ExecutionResult{
Plan: plan,
CompletedActions: []ActionResult{},
FailedActions: []ActionResult{},
Error: fmt.Errorf("failed to create deployment strategy: %w", err),
Duration: 0,
}
return result, err
}
// Step 4: Validate strategy can handle this deployment
if err := strategy.Validate(plan); err != nil {
result := &ExecutionResult{
Plan: plan,
CompletedActions: []ActionResult{},
FailedActions: []ActionResult{},
Error: fmt.Errorf("strategy validation failed: %w", err),
Duration: 0,
}
return result, err
}
// Step 5: Execute the deployment strategy
rm.logf("Estimated deployment duration: %v", strategy.EstimateDuration(plan))
result, err := strategy.Execute(ctx, plan, config, manifestContent)
// Step 6: Handle rollback if needed
if err != nil && rm.rollbackOnFail && result != nil {
rm.logf("Deployment failed, attempting rollback...")
if rollbackErr := rm.RollbackDeployment(ctx, result); rollbackErr != nil {
rm.logf("Rollback failed: %v", rollbackErr)
} else {
result.RollbackPerformed = true
result.RollbackSuccess = true
}
}
if result != nil && result.Success {
rm.logf("Deployment completed successfully in %v", result.Duration)
}
return result, err
}
// ValidatePrerequisites checks if deployment prerequisites are met
func (rm *EdgeConnectResourceManager) ValidatePrerequisites(ctx context.Context, plan *DeploymentPlan) error {
rm.logf("Validating deployment prerequisites for: %s", plan.ConfigName)
// Check if we have any actions to perform
if plan.IsEmpty() {
return fmt.Errorf("deployment plan is empty - no actions to perform")
}
// Validate that we have required client capabilities
if rm.client == nil {
return fmt.Errorf("EdgeConnect client is not configured")
}
rm.logf("Prerequisites validation passed")
return nil
}
// RollbackDeployment attempts to rollback a failed deployment
func (rm *EdgeConnectResourceManager) RollbackDeployment(ctx context.Context, result *ExecutionResult) error {
rm.logf("Starting rollback for deployment: %s", result.Plan.ConfigName)
rollbackErrors := []error{}
// 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-- {
action := result.CompletedActions[i]
switch action.Type {
case ActionCreate:
if err := rm.rollbackCreateAction(ctx, action, result.Plan); err != nil {
rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to rollback %s: %w", action.Target, err))
} else {
rm.logf("Successfully rolled back: %s", action.Target)
}
}
}
// 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 {
return fmt.Errorf("rollback encountered %d errors: %v", len(rollbackErrors), rollbackErrors)
}
rm.logf("Rollback completed successfully")
return nil
}
// rollbackCreateAction rolls back a CREATE action by deleting the resource
func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
if action.Type != ActionCreate {
return nil
}
// Determine if this is an app or instance rollback based on the target name
isInstance := false
for _, instanceAction := range plan.InstanceActions {
if instanceAction.InstanceName == action.Target {
isInstance = true
break
}
}
if isInstance {
return rm.rollbackInstance(ctx, action, plan)
} else {
return rm.rollbackApp(ctx, action, plan)
}
}
// rollbackApp deletes an application that was created
func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
appKey := v2.AppKey{
Organization: plan.AppAction.Desired.Organization,
Name: plan.AppAction.Desired.Name,
Version: plan.AppAction.Desired.Version,
}
return rm.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region)
}
// rollbackInstance deletes an instance that was created
func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
// Find the instance action to get the details
for _, instanceAction := range plan.InstanceActions {
if instanceAction.InstanceName == action.Target {
instanceKey := v2.AppInstanceKey{
Organization: plan.AppAction.Desired.Organization,
Name: instanceAction.InstanceName,
CloudletKey: v2.CloudletKey{
Organization: instanceAction.Target.CloudletOrg,
Name: instanceAction.Target.CloudletName,
},
}
return rm.client.DeleteAppInstance(ctx, instanceKey, instanceAction.Target.Region)
}
}
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
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
if rm.logger != nil {
rm.logger.Printf("[ResourceManager] "+format, v...)
}
}

View file

@ -1,603 +0,0 @@
// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios
// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients
package v2
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// MockResourceClient extends MockEdgeConnectClient with resource management methods
type MockResourceClient struct {
MockEdgeConnectClient
}
func (m *MockResourceClient) CreateApp(ctx context.Context, input *v2.NewAppInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
args := m.Called(ctx, appKey, region)
return args.Error(0)
}
func (m *MockResourceClient) UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
args := m.Called(ctx, instanceKey, region)
return args.Error(0)
}
// TestLogger implements Logger interface for testing
type TestLogger struct {
messages []string
}
func (l *TestLogger) Printf(format string, v ...interface{}) {
l.messages = append(l.messages, fmt.Sprintf(format, v...))
}
func TestNewResourceManager(t *testing.T) {
mockClient := &MockResourceClient{}
manager := NewResourceManager(mockClient)
assert.NotNil(t, manager)
assert.IsType(t, &EdgeConnectResourceManager{}, manager)
}
func TestDefaultResourceManagerOptions(t *testing.T) {
opts := DefaultResourceManagerOptions()
assert.Equal(t, 5, opts.ParallelLimit)
assert.True(t, opts.RollbackOnFail)
assert.Equal(t, 2*time.Minute, opts.OperationTimeout)
}
func TestWithOptions(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient,
WithParallelLimit(10),
WithRollbackOnFail(false),
WithLogger(logger),
)
// Cast to implementation to check options were applied
impl := manager.(*EdgeConnectResourceManager)
assert.Equal(t, 10, impl.parallelLimit)
assert.False(t, impl.rollbackOnFail)
assert.Equal(t, logger, impl.logger)
}
func createTestDeploymentPlan() *DeploymentPlan {
return &DeploymentPlan{
ConfigName: "test-deployment",
AppAction: AppAction{
Type: ActionCreate,
Desired: &AppState{
Name: "test-app",
Version: "1.0.0",
Organization: "testorg",
Region: "US",
},
},
InstanceActions: []InstanceAction{
{
Type: ActionCreate,
Target: config.InfraTemplate{
Region: "US",
CloudletOrg: "cloudletorg",
CloudletName: "cloudlet1",
FlavorName: "small",
},
Desired: &InstanceState{
Name: "test-app-1.0.0-instance",
AppName: "test-app",
},
InstanceName: "test-app-1.0.0-instance",
},
},
}
}
func createTestManagerConfig(t *testing.T) *config.EdgeConnectConfig {
// Create temporary manifest file
tempDir := t.TempDir()
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
require.NoError(t, err)
return &config.EdgeConnectConfig{
Kind: "edgeconnect-deployment",
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "testorg",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{
ManifestFile: manifestFile,
},
InfraTemplate: []config.InfraTemplate{
{
Region: "US",
CloudletOrg: "cloudletorg",
CloudletName: "cloudlet1",
FlavorName: "small",
},
},
Network: &config.NetworkConfig{
OutboundConnections: []config.OutboundConnection{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
},
},
}
}
// createTestStrategyConfig returns a fast configuration for tests
func createTestStrategyConfig() StrategyConfig {
return StrategyConfig{
MaxRetries: 0, // No retries for fast tests
HealthCheckTimeout: 1 * time.Millisecond,
ParallelOperations: false, // Sequential for predictable tests
RetryDelay: 0, // No delay
}
}
func TestApplyDeploymentSuccess(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan()
config := createTestManagerConfig(t)
// Mock successful operations
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
Return(nil)
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
Return(nil)
ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.Success)
assert.Len(t, result.CompletedActions, 2) // 1 app + 1 instance
assert.Len(t, result.FailedActions, 0)
assert.False(t, result.RollbackPerformed)
assert.Greater(t, result.Duration, time.Duration(0))
// Check that operations were logged
assert.Greater(t, len(logger.messages), 0)
mockClient.AssertExpectations(t)
}
func TestApplyDeploymentAppFailure(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan()
config := createTestManagerConfig(t)
// Mock app creation failure - deployment should stop here
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
Return(&v2.APIError{StatusCode: 500, Messages: []string{"Server error"}})
ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
require.Error(t, err)
require.NotNil(t, result)
assert.False(t, result.Success)
assert.Len(t, result.CompletedActions, 0)
assert.Len(t, result.FailedActions, 1)
assert.Contains(t, err.Error(), "Server error")
mockClient.AssertExpectations(t)
}
func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig()))
plan := createTestDeploymentPlan()
config := createTestManagerConfig(t)
// Mock successful app creation but failed instance creation
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
Return(nil)
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
Return(&v2.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}})
// Mock rollback operations
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil)
ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
require.Error(t, err)
require.NotNil(t, result)
assert.False(t, result.Success)
assert.Len(t, result.CompletedActions, 1) // App was created
assert.Len(t, result.FailedActions, 1) // Instance failed
assert.True(t, result.RollbackPerformed)
assert.True(t, result.RollbackSuccess)
assert.Contains(t, err.Error(), "failed to create instance")
mockClient.AssertExpectations(t)
}
func TestApplyDeploymentNoActions(t *testing.T) {
mockClient := &MockResourceClient{}
manager := NewResourceManager(mockClient)
// Create empty plan
plan := &DeploymentPlan{
ConfigName: "empty-plan",
AppAction: AppAction{Type: ActionNone},
}
config := createTestManagerConfig(t)
ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
require.Error(t, err)
require.NotNil(t, result)
assert.Contains(t, err.Error(), "deployment plan is empty")
mockClient.AssertNotCalled(t, "CreateApp")
mockClient.AssertNotCalled(t, "CreateAppInstance")
}
func TestApplyDeploymentMultipleInstances(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig()))
// Create plan with multiple instances
plan := &DeploymentPlan{
ConfigName: "multi-instance",
AppAction: AppAction{
Type: ActionCreate,
Desired: &AppState{
Name: "test-app",
Version: "1.0.0",
Organization: "testorg",
Region: "US",
},
},
InstanceActions: []InstanceAction{
{
Type: ActionCreate,
Target: config.InfraTemplate{
Region: "US",
CloudletOrg: "cloudletorg1",
CloudletName: "cloudlet1",
FlavorName: "small",
},
Desired: &InstanceState{Name: "instance1"},
InstanceName: "instance1",
},
{
Type: ActionCreate,
Target: config.InfraTemplate{
Region: "EU",
CloudletOrg: "cloudletorg2",
CloudletName: "cloudlet2",
FlavorName: "medium",
},
Desired: &InstanceState{Name: "instance2"},
InstanceName: "instance2",
},
},
}
config := createTestManagerConfig(t)
// Mock successful operations
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
Return(nil)
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
Return(nil)
ctx := context.Background()
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.Success)
assert.Len(t, result.CompletedActions, 3) // 1 app + 2 instances
assert.Len(t, result.FailedActions, 0)
mockClient.AssertExpectations(t)
}
func TestValidatePrerequisites(t *testing.T) {
mockClient := &MockResourceClient{}
manager := NewResourceManager(mockClient)
tests := []struct {
name string
plan *DeploymentPlan
wantErr bool
errMsg string
}{
{
name: "valid plan",
plan: &DeploymentPlan{
ConfigName: "test",
AppAction: AppAction{Type: ActionCreate, Desired: &AppState{}},
},
wantErr: false,
},
{
name: "empty plan",
plan: &DeploymentPlan{
ConfigName: "test",
AppAction: AppAction{Type: ActionNone},
},
wantErr: true,
errMsg: "deployment plan is empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
err := manager.ValidatePrerequisites(ctx, tt.plan)
if tt.wantErr {
assert.Error(t, err)
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestRollbackDeployment(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
// Create result with completed actions
plan := createTestDeploymentPlan()
result := &ExecutionResult{
Plan: plan,
CompletedActions: []ActionResult{
{
Type: ActionCreate,
Target: "test-app",
Success: true,
},
{
Type: ActionCreate,
Target: "test-app-1.0.0-instance",
Success: true,
},
},
FailedActions: []ActionResult{},
}
// Mock rollback operations
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(nil)
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil)
ctx := context.Background()
err := manager.RollbackDeployment(ctx, result)
require.NoError(t, err)
mockClient.AssertExpectations(t)
// Check rollback was logged
assert.Greater(t, len(logger.messages), 0)
}
func TestRollbackDeploymentFailure(t *testing.T) {
mockClient := &MockResourceClient{}
manager := NewResourceManager(mockClient)
plan := createTestDeploymentPlan()
result := &ExecutionResult{
Plan: plan,
CompletedActions: []ActionResult{
{
Type: ActionCreate,
Target: "test-app",
Success: true,
},
},
}
// Mock rollback failure
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(&v2.APIError{StatusCode: 500, Messages: []string{"Delete failed"}})
ctx := context.Background()
err := manager.RollbackDeployment(ctx, result)
require.Error(t, err)
assert.Contains(t, err.Error(), "rollback encountered")
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) {
network := &config.NetworkConfig{
OutboundConnections: []config.OutboundConnection{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 443,
PortRangeMax: 443,
RemoteCIDR: "10.0.0.0/8",
},
},
}
rules := convertNetworkRules(network)
require.Len(t, rules, 2)
assert.Equal(t, "tcp", rules[0].Protocol)
assert.Equal(t, 80, rules[0].PortRangeMin)
assert.Equal(t, 80, rules[0].PortRangeMax)
assert.Equal(t, "0.0.0.0/0", rules[0].RemoteCIDR)
assert.Equal(t, "tcp", rules[1].Protocol)
assert.Equal(t, 443, rules[1].PortRangeMin)
assert.Equal(t, 443, rules[1].PortRangeMax)
assert.Equal(t, "10.0.0.0/8", rules[1].RemoteCIDR)
}

View file

@ -1,663 +0,0 @@
// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios
// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios
package v2
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// MockEdgeConnectClient is a mock implementation of the EdgeConnect client
type MockEdgeConnectClient struct {
mock.Mock
}
func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) {
args := m.Called(ctx, appKey, region)
if args.Get(0) == nil {
return v2.App{}, args.Error(1)
}
return args.Get(0).(v2.App), args.Error(1)
}
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) (v2.AppInstance, error) {
args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil {
return v2.AppInstance{}, args.Error(1)
}
return args.Get(0).(v2.AppInstance), args.Error(1)
}
func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *v2.NewAppInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
args := m.Called(ctx, appKey, region)
return args.Error(0)
}
func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error {
args := m.Called(ctx, input)
return args.Error(0)
}
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
args := m.Called(ctx, instanceKey, region)
return args.Error(0)
}
func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey v2.AppKey, region string) ([]v2.App, error) {
args := m.Called(ctx, appKey, region)
if args.Get(0) == nil {
return nil, 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) {
args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]v2.AppInstance), args.Error(1)
}
func TestNewPlanner(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
assert.NotNil(t, planner)
assert.IsType(t, &EdgeConnectPlanner{}, planner)
}
func TestDefaultPlanOptions(t *testing.T) {
opts := DefaultPlanOptions()
assert.False(t, opts.DryRun)
assert.False(t, opts.Force)
assert.False(t, opts.SkipStateCheck)
assert.True(t, opts.ParallelQueries)
assert.Equal(t, 30*time.Second, opts.Timeout)
}
func createTestConfig(t *testing.T) *config.EdgeConnectConfig {
// Create temporary manifest file
tempDir := t.TempDir()
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
require.NoError(t, err)
return &config.EdgeConnectConfig{
Kind: "edgeconnect-deployment",
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "testorg",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{
ManifestFile: manifestFile,
},
InfraTemplate: []config.InfraTemplate{
{
Region: "US",
CloudletOrg: "TestCloudletOrg",
CloudletName: "TestCloudlet",
FlavorName: "small",
},
},
Network: &config.NetworkConfig{
OutboundConnections: []config.OutboundConnection{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
},
},
}
}
func TestPlanNewDeployment(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Mock API calls to return "not found" errors
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
require.NoError(t, result.Error)
plan := result.Plan
assert.Equal(t, "test-app", plan.ConfigName)
assert.Equal(t, ActionCreate, plan.AppAction.Type)
assert.Equal(t, "Application does not exist", plan.AppAction.Reason)
require.Len(t, plan.InstanceActions, 1)
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
assert.Equal(t, "Instance does not exist", plan.InstanceActions[0].Reason)
assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance
assert.False(t, plan.IsEmpty())
mockClient.AssertExpectations(t)
}
func TestPlanExistingDeploymentNoChanges(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Note: We would calculate expected manifest hash here when API supports it
// Mock existing app with same manifest hash and outbound connections
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
existingApp := &v2.App{
Key: v2.AppKey{
Organization: "testorg",
Name: "test-app",
Version: "1.0.0",
},
Deployment: "kubernetes",
DeploymentManifest: manifestContent,
RequiredOutboundConnections: []v2.SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
},
// Note: Manifest hash tracking would be implemented when API supports annotations
}
// Mock existing instance
existingInstance := &v2.AppInstance{
Key: v2.AppInstanceKey{
Organization: "testorg",
Name: "test-app-1.0.0-instance",
CloudletKey: v2.CloudletKey{
Organization: "TestCloudletOrg",
Name: "TestCloudlet",
},
},
AppKey: v2.AppKey{
Organization: "testorg",
Name: "test-app",
Version: "1.0.0",
},
Flavor: v2.Flavor{
Name: "small",
},
State: "Ready",
PowerState: "PowerOn",
}
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(*existingApp, nil)
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(*existingInstance, nil)
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.Equal(t, ActionNone, plan.AppAction.Type)
assert.Len(t, plan.InstanceActions, 1)
assert.Equal(t, ActionNone, plan.InstanceActions[0].Type)
assert.Equal(t, 0, plan.TotalActions)
assert.True(t, plan.IsEmpty())
assert.Contains(t, plan.Summary, "No changes required")
mockClient.AssertExpectations(t)
}
func TestPlanWithOptions(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
opts := PlanOptions{
DryRun: true,
SkipStateCheck: true,
Timeout: 10 * time.Second,
}
ctx := context.Background()
result, err := planner.PlanWithOptions(ctx, testConfig, opts)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.True(t, plan.DryRun)
assert.Equal(t, ActionCreate, plan.AppAction.Type)
assert.Contains(t, plan.AppAction.Reason, "state check skipped")
// No API calls should be made when SkipStateCheck is true
mockClient.AssertNotCalled(t, "ShowApp")
mockClient.AssertNotCalled(t, "ShowAppInstance")
}
func TestPlanMultipleInfrastructures(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Add a second infrastructure target
testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{
Region: "EU",
CloudletOrg: "EUCloudletOrg",
CloudletName: "EUCloudlet",
FlavorName: "medium",
})
// Mock API calls to return "not found" errors
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "EU").
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.Equal(t, ActionCreate, plan.AppAction.Type)
// Should have 2 instance actions, one for each infrastructure
require.Len(t, plan.InstanceActions, 2)
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
assert.Equal(t, ActionCreate, plan.InstanceActions[1].Type)
assert.Equal(t, 3, plan.TotalActions) // 1 app + 2 instances
// Test cloudlet and region aggregation
cloudlets := plan.GetTargetCloudlets()
regions := plan.GetTargetRegions()
assert.Len(t, cloudlets, 2)
assert.Len(t, regions, 2)
mockClient.AssertExpectations(t)
}
func TestCalculateManifestHash(t *testing.T) {
planner := &EdgeConnectPlanner{}
tempDir := t.TempDir()
// Create test file
testFile := filepath.Join(tempDir, "test.yaml")
content := "test content for hashing"
err := os.WriteFile(testFile, []byte(content), 0644)
require.NoError(t, err)
hash1, err := planner.calculateManifestHash(testFile)
require.NoError(t, err)
assert.NotEmpty(t, hash1)
assert.Len(t, hash1, 64) // SHA256 hex string length
// Same content should produce same hash
hash2, err := planner.calculateManifestHash(testFile)
require.NoError(t, err)
assert.Equal(t, hash1, hash2)
// Different content should produce different hash
err = os.WriteFile(testFile, []byte("different content"), 0644)
require.NoError(t, err)
hash3, err := planner.calculateManifestHash(testFile)
require.NoError(t, err)
assert.NotEqual(t, hash1, hash3)
// Empty file path should return empty hash
hash4, err := planner.calculateManifestHash("")
require.NoError(t, err)
assert.Empty(t, hash4)
// Non-existent file should return error
_, err = planner.calculateManifestHash("/non/existent/file")
assert.Error(t, err)
}
func TestCompareAppStates(t *testing.T) {
planner := &EdgeConnectPlanner{}
current := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
ManifestHash: "old-hash",
}
desired := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
ManifestHash: "new-hash",
}
changes, manifestChanged := planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.True(t, manifestChanged)
assert.Contains(t, changes[0], "Manifest hash changed")
// Test no changes
desired.ManifestHash = "old-hash"
changes, manifestChanged = planner.compareAppStates(current, desired)
assert.Empty(t, changes)
assert.False(t, manifestChanged)
// Test app type change
desired.AppType = AppTypeDocker
changes, manifestChanged = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.False(t, manifestChanged)
assert.Contains(t, changes[0], "App type changed")
}
func TestCompareAppStatesOutboundConnections(t *testing.T) {
planner := &EdgeConnectPlanner{}
// Test with no outbound connections
current := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
OutboundConnections: nil,
}
desired := &AppState{
Name: "test-app",
Version: "1.0.0",
AppType: AppTypeK8s,
OutboundConnections: nil,
}
changes, _ := planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when both have no outbound connections")
// Test adding outbound connections
desired.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
// Test identical outbound connections
current.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when outbound connections are identical")
// Test different outbound connections (different port)
desired.OutboundConnections[0].PortRangeMin = 443
desired.OutboundConnections[0].PortRangeMax = 443
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
// Test same connections but different order (should be considered equal)
current.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 443,
PortRangeMax: 443,
RemoteCIDR: "0.0.0.0/0",
},
}
desired.OutboundConnections = []SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 443,
PortRangeMax: 443,
RemoteCIDR: "0.0.0.0/0",
},
{
Protocol: "tcp",
PortRangeMin: 80,
PortRangeMax: 80,
RemoteCIDR: "0.0.0.0/0",
},
}
changes, _ = planner.compareAppStates(current, desired)
assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order")
// Test removing outbound connections
desired.OutboundConnections = nil
changes, _ = planner.compareAppStates(current, desired)
assert.Len(t, changes, 1)
assert.Contains(t, changes[0], "Outbound connections changed")
}
func TestCompareInstanceStates(t *testing.T) {
planner := &EdgeConnectPlanner{}
current := &InstanceState{
Name: "test-instance",
FlavorName: "small",
CloudletName: "oldcloudlet",
CloudletOrg: "oldorg",
}
desired := &InstanceState{
Name: "test-instance",
FlavorName: "medium",
CloudletName: "newcloudlet",
CloudletOrg: "neworg",
}
changes := planner.compareInstanceStates(current, desired)
assert.Len(t, changes, 3)
assert.Contains(t, changes[0], "Flavor changed")
assert.Contains(t, changes[1], "Cloudlet changed")
assert.Contains(t, changes[2], "Cloudlet org changed")
// Test no changes
desired.FlavorName = "small"
desired.CloudletName = "oldcloudlet"
desired.CloudletOrg = "oldorg"
changes = planner.compareInstanceStates(current, desired)
assert.Empty(t, changes)
}
func TestDeploymentPlanMethods(t *testing.T) {
plan := &DeploymentPlan{
ConfigName: "test-plan",
AppAction: AppAction{
Type: ActionCreate,
Desired: &AppState{Name: "test-app"},
},
InstanceActions: []InstanceAction{
{
Type: ActionCreate,
Target: config.InfraTemplate{
CloudletOrg: "org1",
CloudletName: "cloudlet1",
Region: "US",
},
InstanceName: "instance1",
Desired: &InstanceState{Name: "instance1"},
},
{
Type: ActionUpdate,
Target: config.InfraTemplate{
CloudletOrg: "org2",
CloudletName: "cloudlet2",
Region: "EU",
},
InstanceName: "instance2",
Desired: &InstanceState{Name: "instance2"},
},
},
}
// Test IsEmpty
assert.False(t, plan.IsEmpty())
// Test GetTargetCloudlets
cloudlets := plan.GetTargetCloudlets()
assert.Len(t, cloudlets, 2)
assert.Contains(t, cloudlets, "org1:cloudlet1")
assert.Contains(t, cloudlets, "org2:cloudlet2")
// Test GetTargetRegions
regions := plan.GetTargetRegions()
assert.Len(t, regions, 2)
assert.Contains(t, regions, "US")
assert.Contains(t, regions, "EU")
// Test GenerateSummary
summary := plan.GenerateSummary()
assert.Contains(t, summary, "test-plan")
assert.Contains(t, summary, "CREATE application")
assert.Contains(t, summary, "CREATE 1 instance")
assert.Contains(t, summary, "UPDATE 1 instance")
// Test Validate
err := plan.Validate()
assert.NoError(t, err)
// Test validation failure
plan.AppAction.Desired = nil
err = plan.Validate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "must have desired state")
}
func TestEstimateDeploymentDuration(t *testing.T) {
planner := &EdgeConnectPlanner{}
plan := &DeploymentPlan{
AppAction: AppAction{Type: ActionCreate},
InstanceActions: []InstanceAction{
{Type: ActionCreate},
{Type: ActionUpdate},
},
}
duration := planner.estimateDeploymentDuration(plan)
assert.Greater(t, duration, time.Duration(0))
assert.Less(t, duration, 10*time.Minute) // Reasonable upper bound
// Test with no actions
emptyPlan := &DeploymentPlan{
AppAction: AppAction{Type: ActionNone},
InstanceActions: []InstanceAction{},
}
emptyDuration := planner.estimateDeploymentDuration(emptyPlan)
assert.Greater(t, emptyDuration, time.Duration(0))
assert.Less(t, emptyDuration, duration) // Should be less than plan with actions
}
func TestIsResourceNotFoundError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"nil error", nil, false},
{"not found error", &v2.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true},
{"does not exist error", &v2.APIError{Messages: []string{"App does not exist"}}, true},
{"404 in message", &v2.APIError{Messages: []string{"HTTP 404 error"}}, true},
{"other error", &v2.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isResourceNotFoundError(tt.err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestPlanErrorHandling(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Mock API call to return a non-404 error
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil, &v2.APIError{StatusCode: 500, Messages: []string{"Server error"}})
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
assert.Error(t, err)
assert.NotNil(t, result)
assert.NotNil(t, result.Error)
assert.Contains(t, err.Error(), "failed to query current app state")
mockClient.AssertExpectations(t)
}

View file

@ -1,641 +0,0 @@
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
package v2
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
)
// RecreateStrategy implements the recreate deployment strategy
type RecreateStrategy struct {
client EdgeConnectClientInterface
config StrategyConfig
logger Logger
}
// NewRecreateStrategy creates a new recreate strategy executor
func NewRecreateStrategy(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *RecreateStrategy {
return &RecreateStrategy{
client: client,
config: config,
logger: logger,
}
}
// GetName returns the strategy name
func (r *RecreateStrategy) GetName() DeploymentStrategy {
return StrategyRecreate
}
// Validate checks if the recreate strategy can be used for this deployment
func (r *RecreateStrategy) Validate(plan *DeploymentPlan) error {
// Recreate strategy can be used for any deployment
// No specific constraints for recreate
return nil
}
// EstimateDuration estimates the time needed for recreate deployment
func (r *RecreateStrategy) EstimateDuration(plan *DeploymentPlan) time.Duration {
var duration time.Duration
// Delete phase - estimate based on number of instances
instanceCount := len(plan.InstanceActions)
if instanceCount > 0 {
deleteTime := time.Duration(instanceCount) * 30 * time.Second
if r.config.ParallelOperations {
deleteTime = 30 * time.Second // Parallel deletion
}
duration += deleteTime
}
// App update phase
if plan.AppAction.Type == ActionUpdate {
duration += 30 * time.Second
}
// Create phase - estimate based on number of instances
if instanceCount > 0 {
createTime := time.Duration(instanceCount) * 2 * time.Minute
if r.config.ParallelOperations {
createTime = 2 * time.Minute // Parallel creation
}
duration += createTime
}
// Health check time
duration += r.config.HealthCheckTimeout
// Add retry buffer (potential retries)
retryBuffer := time.Duration(r.config.MaxRetries) * r.config.RetryDelay
duration += retryBuffer
return duration
}
// Execute runs the recreate deployment strategy
func (r *RecreateStrategy) Execute(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) {
startTime := time.Now()
r.logf("Starting recreate deployment strategy for: %s", plan.ConfigName)
result := &ExecutionResult{
Plan: plan,
CompletedActions: []ActionResult{},
FailedActions: []ActionResult{},
}
// Phase 1: Delete all existing instances
if err := r.deleteInstancesPhase(ctx, plan, config, result); err != nil {
result.Error = err
result.Duration = time.Since(startTime)
return result, err
}
// Phase 2: Delete existing app (if updating)
if err := r.deleteAppPhase(ctx, plan, config, result); err != nil {
result.Error = err
result.Duration = time.Since(startTime)
return result, err
}
// Phase 3: Create/recreate application
if err := r.createAppPhase(ctx, plan, config, manifestContent, result); err != nil {
result.Error = err
result.Duration = time.Since(startTime)
return result, err
}
// Phase 4: Create new instances
if err := r.createInstancesPhase(ctx, plan, config, result); err != nil {
result.Error = err
result.Duration = time.Since(startTime)
return result, err
}
// Phase 5: Health check (wait for instances to be ready)
if err := r.healthCheckPhase(ctx, plan, result); err != nil {
result.Error = err
result.Duration = time.Since(startTime)
return result, err
}
result.Success = len(result.FailedActions) == 0
result.Duration = time.Since(startTime)
if result.Success {
r.logf("Recreate deployment completed successfully in %v", result.Duration)
} else {
r.logf("Recreate deployment failed with %d failed actions", len(result.FailedActions))
}
return result, result.Error
}
// deleteInstancesPhase deletes all existing instances
func (r *RecreateStrategy) deleteInstancesPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
r.logf("Phase 1: Deleting existing instances")
// Only delete instances that exist (have ActionUpdate or ActionNone type)
instancesToDelete := []InstanceAction{}
for _, action := range plan.InstanceActions {
if action.Type == ActionUpdate || action.Type == ActionNone {
// Convert to delete action
deleteAction := action
deleteAction.Type = ActionDelete
deleteAction.Reason = "Recreate strategy: deleting for recreation"
instancesToDelete = append(instancesToDelete, deleteAction)
}
}
if len(instancesToDelete) == 0 {
r.logf("No existing instances to delete")
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)
for _, deleteResult := range deleteResults {
if deleteResult.Success {
result.CompletedActions = append(result.CompletedActions, deleteResult)
r.logf("Deleted instance: %s", deleteResult.Target)
} else {
result.FailedActions = append(result.FailedActions, deleteResult)
return fmt.Errorf("failed to delete instance %s: %w", deleteResult.Target, deleteResult.Error)
}
}
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
}
// deleteAppPhase deletes the existing app (if updating)
func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
if plan.AppAction.Type != ActionUpdate {
r.logf("Phase 2: No app deletion needed (new app)")
return nil
}
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{
Organization: plan.AppAction.Desired.Organization,
Name: plan.AppAction.Desired.Name,
Version: plan.AppAction.Desired.Version,
}
if err := r.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region); err != nil {
result.FailedActions = append(result.FailedActions, ActionResult{
Type: ActionDelete,
Target: plan.AppAction.Desired.Name,
Success: false,
Error: err,
})
return fmt.Errorf("failed to delete app: %w", err)
}
result.CompletedActions = append(result.CompletedActions, ActionResult{
Type: ActionDelete,
Target: plan.AppAction.Desired.Name,
Success: true,
Details: fmt.Sprintf("Deleted app %s", plan.AppAction.Desired.Name),
})
r.logf("Phase 2 complete: deleted existing application")
return nil
}
// createAppPhase creates the application (always create since we deleted it first)
func (r *RecreateStrategy) createAppPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string, result *ExecutionResult) error {
if plan.AppAction.Type == ActionNone {
r.logf("Phase 3: No app creation needed")
return nil
}
r.logf("Phase 3: Creating application")
// Always use create since recreate strategy deletes first
createAction := plan.AppAction
createAction.Type = ActionCreate
createAction.Reason = "Recreate strategy: creating app"
appResult := r.executeAppActionWithRetry(ctx, createAction, config, manifestContent)
if appResult.Success {
result.CompletedActions = append(result.CompletedActions, appResult)
r.logf("Phase 3 complete: app created successfully")
return nil
} else {
result.FailedActions = append(result.FailedActions, appResult)
return fmt.Errorf("failed to create app: %w", appResult.Error)
}
}
// createInstancesPhase creates new instances
func (r *RecreateStrategy) createInstancesPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
r.logf("Phase 4: Creating new instances")
// Convert all instance actions to create
instancesToCreate := []InstanceAction{}
for _, action := range plan.InstanceActions {
createAction := action
createAction.Type = ActionCreate
createAction.Reason = "Recreate strategy: creating new instance"
instancesToCreate = append(instancesToCreate, createAction)
}
if len(instancesToCreate) == 0 {
r.logf("No instances to create")
return nil
}
createResults := r.executeInstanceActionsWithRetry(ctx, instancesToCreate, "create", config)
for _, createResult := range createResults {
if createResult.Success {
result.CompletedActions = append(result.CompletedActions, createResult)
r.logf("Created instance: %s", createResult.Target)
} else {
result.FailedActions = append(result.FailedActions, createResult)
return fmt.Errorf("failed to create instance %s: %w", createResult.Target, createResult.Error)
}
}
r.logf("Phase 4 complete: created %d instances", len(createResults))
return nil
}
// healthCheckPhase waits for instances to become ready
func (r *RecreateStrategy) healthCheckPhase(ctx context.Context, plan *DeploymentPlan, result *ExecutionResult) error {
if len(plan.InstanceActions) == 0 {
return nil
}
r.logf("Phase 5: Performing health checks")
// TODO: Implement actual health checks by querying instance status
// For now, skip waiting in tests/mock environments
r.logf("Phase 5 complete: health check passed (no wait)")
return nil
}
// executeInstanceActionsWithRetry executes instance actions with retry logic
func (r *RecreateStrategy) executeInstanceActionsWithRetry(ctx context.Context, actions []InstanceAction, operation string, config *config.EdgeConnectConfig) []ActionResult {
results := make([]ActionResult, len(actions))
if r.config.ParallelOperations && len(actions) > 1 {
// Parallel execution
var wg sync.WaitGroup
semaphore := make(chan struct{}, 5) // Limit concurrency
for i, action := range actions {
wg.Add(1)
go func(index int, instanceAction InstanceAction) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
results[index] = r.executeInstanceActionWithRetry(ctx, instanceAction, operation, config)
}(i, action)
}
wg.Wait()
} else {
// Sequential execution
for i, action := range actions {
results[i] = r.executeInstanceActionWithRetry(ctx, action, operation, config)
}
}
return results
}
// executeInstanceActionWithRetry executes a single instance action with retry logic
func (r *RecreateStrategy) executeInstanceActionWithRetry(ctx context.Context, action InstanceAction, operation string, config *config.EdgeConnectConfig) ActionResult {
startTime := time.Now()
result := ActionResult{
Type: action.Type,
Target: action.InstanceName,
}
var lastErr error
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
if attempt > 0 {
r.logf("Retrying %s for instance %s (attempt %d/%d)", operation, action.InstanceName, attempt, r.config.MaxRetries)
select {
case <-time.After(r.config.RetryDelay):
case <-ctx.Done():
result.Error = ctx.Err()
result.Duration = time.Since(startTime)
return result
}
}
var success bool
var err error
switch action.Type {
case ActionDelete:
success, err = r.deleteInstance(ctx, action)
case ActionCreate:
success, err = r.createInstance(ctx, action, config)
default:
err = fmt.Errorf("unsupported action type: %s", action.Type)
}
if success {
result.Success = true
result.Details = fmt.Sprintf("Successfully %sd instance %s", strings.ToLower(string(action.Type)), action.InstanceName)
result.Duration = time.Since(startTime)
return result
}
lastErr = err
// Check if error is retryable (don't retry 4xx client errors)
if !isRetryableError(err) {
r.logf("Failed to %s instance %s: %v (non-retryable error, giving up)", operation, action.InstanceName, err)
result.Error = fmt.Errorf("non-retryable error: %w", err)
result.Duration = time.Since(startTime)
return result
}
if attempt < r.config.MaxRetries {
r.logf("Failed to %s instance %s: %v (will retry)", operation, action.InstanceName, err)
}
}
result.Error = fmt.Errorf("failed after %d attempts: %w", r.config.MaxRetries+1, lastErr)
result.Duration = time.Since(startTime)
return result
}
// executeAppActionWithRetry executes app action with retry logic
func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) ActionResult {
startTime := time.Now()
result := ActionResult{
Type: action.Type,
Target: action.Desired.Name,
}
var lastErr error
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
if attempt > 0 {
r.logf("Retrying app update (attempt %d/%d)", attempt, r.config.MaxRetries)
select {
case <-time.After(r.config.RetryDelay):
case <-ctx.Done():
result.Error = ctx.Err()
result.Duration = time.Since(startTime)
return result
}
}
success, err := r.updateApplication(ctx, action, config, manifestContent)
if success {
result.Success = true
result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name)
result.Duration = time.Since(startTime)
return result
}
lastErr = err
// Check if error is retryable (don't retry 4xx client errors)
if !isRetryableError(err) {
r.logf("Failed to update app: %v (non-retryable error, giving up)", err)
result.Error = fmt.Errorf("non-retryable error: %w", err)
result.Duration = time.Since(startTime)
return result
}
if attempt < r.config.MaxRetries {
r.logf("Failed to update app: %v (will retry)", err)
}
}
result.Error = fmt.Errorf("failed after %d attempts: %w", r.config.MaxRetries+1, lastErr)
result.Duration = time.Since(startTime)
return result
}
// deleteInstance deletes an instance (reuse existing logic from manager.go)
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
instanceKey := v2.AppInstanceKey{
Organization: action.Desired.Organization,
Name: action.InstanceName,
CloudletKey: v2.CloudletKey{
Organization: action.Target.CloudletOrg,
Name: action.Target.CloudletName,
},
}
err := r.client.DeleteAppInstance(ctx, instanceKey, action.Target.Region)
if err != nil {
return false, fmt.Errorf("failed to delete instance: %w", err)
}
return true, nil
}
// createInstance creates an instance (extracted from manager.go logic)
func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
instanceInput := &v2.NewAppInstanceInput{
Region: action.Target.Region,
AppInst: v2.AppInstance{
Key: v2.AppInstanceKey{
Organization: action.Desired.Organization,
Name: action.InstanceName,
CloudletKey: v2.CloudletKey{
Organization: action.Target.CloudletOrg,
Name: action.Target.CloudletName,
},
},
AppKey: v2.AppKey{
Organization: action.Desired.Organization,
Name: config.Metadata.Name,
Version: config.Metadata.AppVersion,
},
Flavor: v2.Flavor{
Name: action.Target.FlavorName,
},
},
}
// Create the instance
if err := r.client.CreateAppInstance(ctx, instanceInput); err != nil {
return false, fmt.Errorf("failed to create instance: %w", err)
}
r.logf("Successfully created instance: %s on %s:%s",
action.InstanceName, action.Target.CloudletOrg, action.Target.CloudletName)
return true, nil
}
// updateApplication creates/recreates an application (always uses CreateApp since we delete first)
func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) {
// Build the app create input - always create since recreate strategy deletes first
appInput := &v2.NewAppInput{
Region: action.Desired.Region,
App: v2.App{
Key: v2.AppKey{
Organization: action.Desired.Organization,
Name: action.Desired.Name,
Version: action.Desired.Version,
},
Deployment: config.GetDeploymentType(),
ImageType: "ImageTypeDocker",
ImagePath: config.GetImagePath(),
AllowServerless: true,
DefaultFlavor: v2.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
ServerlessConfig: struct{}{},
DeploymentManifest: manifestContent,
DeploymentGenerator: "kubernetes-basic",
},
}
// Add network configuration if specified
if config.Spec.Network != nil {
appInput.App.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network)
}
// Create the application (recreate strategy always creates from scratch)
if err := r.client.CreateApp(ctx, appInput); err != nil {
return false, fmt.Errorf("failed to create application: %w", err)
}
r.logf("Successfully created application: %s/%s version %s",
action.Desired.Organization, action.Desired.Name, action.Desired.Version)
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
func (r *RecreateStrategy) logf(format string, v ...interface{}) {
if r.logger != nil {
r.logger.Printf("[RecreateStrategy] "+format, v...)
}
}
// isRetryableError determines if an error should be retried
// Returns false for client errors (4xx), true for server errors (5xx) and other transient errors
func 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
}

View file

@ -1,489 +0,0 @@
// ABOUTME: Deployment planning types for EdgeConnect apply command with state management
// ABOUTME: Defines structures for deployment plans, actions, and state comparison results
package v2
import (
"fmt"
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
)
// SecurityRule defines network access rules (alias to SDK type for consistency)
type SecurityRule = v2.SecurityRule
// ActionType represents the type of action to be performed
type ActionType string
const (
// ActionCreate indicates a resource needs to be created
ActionCreate ActionType = "CREATE"
// ActionUpdate indicates a resource needs to be updated
ActionUpdate ActionType = "UPDATE"
// ActionNone indicates no action is needed
ActionNone ActionType = "NONE"
// ActionDelete indicates a resource needs to be deleted (for rollback scenarios)
ActionDelete ActionType = "DELETE"
)
// String returns the string representation of ActionType
func (a ActionType) String() string {
return string(a)
}
// DeploymentPlan represents the complete deployment plan for a configuration
type DeploymentPlan struct {
// ConfigName is the name from metadata
ConfigName string
// AppAction defines what needs to be done with the application
AppAction AppAction
// InstanceActions defines what needs to be done with each instance
InstanceActions []InstanceAction
// Summary provides a human-readable summary of the plan
Summary string
// TotalActions is the count of all actions that will be performed
TotalActions int
// EstimatedDuration is the estimated time to complete the deployment
EstimatedDuration time.Duration
// CreatedAt timestamp when the plan was created
CreatedAt time.Time
// DryRun indicates if this is a dry-run plan
DryRun bool
}
// AppAction represents an action to be performed on an application
type AppAction struct {
// Type of action to perform
Type ActionType
// Current state of the app (nil if doesn't exist)
Current *AppState
// Desired state of the app
Desired *AppState
// Changes describes what will change
Changes []string
// Reason explains why this action is needed
Reason string
// ManifestHash is the hash of the current manifest file
ManifestHash string
// ManifestChanged indicates if the manifest content has changed
ManifestChanged bool
}
// InstanceAction represents an action to be performed on an application instance
type InstanceAction struct {
// Type of action to perform
Type ActionType
// Target infrastructure where the instance will be deployed
Target config.InfraTemplate
// Current state of the instance (nil if doesn't exist)
Current *InstanceState
// Desired state of the instance
Desired *InstanceState
// Changes describes what will change
Changes []string
// Reason explains why this action is needed
Reason string
// InstanceName is the generated name for this instance
InstanceName string
// Dependencies lists other instances this depends on
Dependencies []string
}
// AppState represents the current state of an application
type AppState struct {
// Name of the application
Name string
// Version of the application
Version string
// Organization that owns the app
Organization string
// Region where the app is deployed
Region string
// ManifestHash is the stored hash of the manifest file
ManifestHash string
// LastUpdated timestamp when the app was last modified
LastUpdated time.Time
// Exists indicates if the app currently exists
Exists bool
// AppType indicates whether this is a k8s or docker app
AppType AppType
// OutboundConnections contains the required outbound network connections
OutboundConnections []SecurityRule
}
// InstanceState represents the current state of an application instance
type InstanceState struct {
// Name of the instance
Name string
// AppName that this instance belongs to
AppName string
// AppVersion of the associated app
AppVersion string
// Organization that owns the instance
Organization string
// Region where the instance is deployed
Region string
// CloudletOrg that hosts the cloudlet
CloudletOrg string
// CloudletName where the instance is running
CloudletName string
// FlavorName used for the instance
FlavorName string
// State of the instance (e.g., "Ready", "Pending", "Error")
State string
// PowerState of the instance
PowerState string
// LastUpdated timestamp when the instance was last modified
LastUpdated time.Time
// Exists indicates if the instance currently exists
Exists bool
}
// AppType represents the type of application
type AppType string
const (
// AppTypeK8s represents a Kubernetes application
AppTypeK8s AppType = "k8s"
// AppTypeDocker represents a Docker application
AppTypeDocker AppType = "docker"
)
// String returns the string representation of AppType
func (a AppType) String() string {
return string(a)
}
// DeploymentSummary provides a high-level overview of the deployment plan
type DeploymentSummary struct {
// TotalActions is the total number of actions to be performed
TotalActions int
// ActionCounts breaks down actions by type
ActionCounts map[ActionType]int
// EstimatedDuration for the entire deployment
EstimatedDuration time.Duration
// ResourceSummary describes the resources involved
ResourceSummary ResourceSummary
// Warnings about potential issues
Warnings []string
}
// ResourceSummary provides details about resources in the deployment
type ResourceSummary struct {
// AppsToCreate number of apps that will be created
AppsToCreate int
// AppsToUpdate number of apps that will be updated
AppsToUpdate int
// InstancesToCreate number of instances that will be created
InstancesToCreate int
// InstancesToUpdate number of instances that will be updated
InstancesToUpdate int
// CloudletsAffected number of unique cloudlets involved
CloudletsAffected int
// RegionsAffected number of unique regions involved
RegionsAffected int
}
// PlanResult represents the result of a deployment planning operation
type PlanResult struct {
// Plan is the generated deployment plan
Plan *DeploymentPlan
// Error if planning failed
Error error
// Warnings encountered during planning
Warnings []string
}
// ExecutionResult represents the result of executing a deployment plan
type ExecutionResult struct {
// Plan that was executed
Plan *DeploymentPlan
// Success indicates if the deployment was successful
Success bool
// CompletedActions lists actions that were successfully completed
CompletedActions []ActionResult
// FailedActions lists actions that failed
FailedActions []ActionResult
// Error that caused the deployment to fail (if any)
Error error
// Duration taken to execute the plan
Duration time.Duration
// RollbackPerformed indicates if rollback was executed
RollbackPerformed bool
// RollbackSuccess indicates if rollback was successful
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
type ActionResult struct {
// Type of action that was attempted
Type ActionType
// Target describes what was being acted upon
Target string
// Success indicates if the action succeeded
Success bool
// Error if the action failed
Error error
// Duration taken to complete the action
Duration time.Duration
// Details provides additional information about the action
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
func (dp *DeploymentPlan) IsEmpty() bool {
if dp.AppAction.Type != ActionNone {
return false
}
for _, action := range dp.InstanceActions {
if action.Type != ActionNone {
return false
}
}
return true
}
// HasErrors returns true if the plan contains any error conditions
func (dp *DeploymentPlan) HasErrors() bool {
// Check for conflicting actions or invalid states
return false // Implementation would check for various error conditions
}
// GetTargetCloudlets returns a list of unique cloudlets that will be affected
func (dp *DeploymentPlan) GetTargetCloudlets() []string {
cloudletSet := make(map[string]bool)
var cloudlets []string
for _, action := range dp.InstanceActions {
if action.Type != ActionNone {
key := fmt.Sprintf("%s:%s", action.Target.CloudletOrg, action.Target.CloudletName)
if !cloudletSet[key] {
cloudletSet[key] = true
cloudlets = append(cloudlets, key)
}
}
}
return cloudlets
}
// GetTargetRegions returns a list of unique regions that will be affected
func (dp *DeploymentPlan) GetTargetRegions() []string {
regionSet := make(map[string]bool)
var regions []string
for _, action := range dp.InstanceActions {
if action.Type != ActionNone && !regionSet[action.Target.Region] {
regionSet[action.Target.Region] = true
regions = append(regions, action.Target.Region)
}
}
return regions
}
// GenerateSummary creates a human-readable summary of the deployment plan
func (dp *DeploymentPlan) GenerateSummary() string {
if dp.IsEmpty() {
return "No changes required - configuration matches current state"
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName))
// App actions
if dp.AppAction.Type != ActionNone {
sb.WriteString(fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name))
if len(dp.AppAction.Changes) > 0 {
for _, change := range dp.AppAction.Changes {
sb.WriteString(fmt.Sprintf(" - %s\n", change))
}
}
}
// Instance actions
createCount := 0
updateActions := []InstanceAction{}
for _, action := range dp.InstanceActions {
switch action.Type {
case ActionCreate:
createCount++
case ActionUpdate:
updateActions = append(updateActions, action)
}
}
if createCount > 0 {
sb.WriteString(fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets())))
}
if len(updateActions) > 0 {
sb.WriteString(fmt.Sprintf("- UPDATE %d instance(s)\n", len(updateActions)))
for _, action := range updateActions {
if len(action.Changes) > 0 {
sb.WriteString(fmt.Sprintf(" - Instance '%s':\n", action.InstanceName))
for _, change := range action.Changes {
sb.WriteString(fmt.Sprintf(" - %s\n", change))
}
}
}
}
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
return sb.String()
}
// Validate checks if the deployment plan is valid and safe to execute
func (dp *DeploymentPlan) Validate() error {
if dp.ConfigName == "" {
return fmt.Errorf("deployment plan must have a config name")
}
// Validate app action
if dp.AppAction.Type != ActionNone && dp.AppAction.Desired == nil {
return fmt.Errorf("app action of type %s must have desired state", dp.AppAction.Type)
}
// Validate instance actions
for i, action := range dp.InstanceActions {
if action.Type != ActionNone {
if action.Desired == nil {
return fmt.Errorf("instance action %d of type %s must have desired state", i, action.Type)
}
if action.InstanceName == "" {
return fmt.Errorf("instance action %d must have an instance name", i)
}
}
}
return nil
}
// Clone creates a deep copy of the deployment plan
func (dp *DeploymentPlan) Clone() *DeploymentPlan {
clone := &DeploymentPlan{
ConfigName: dp.ConfigName,
Summary: dp.Summary,
TotalActions: dp.TotalActions,
EstimatedDuration: dp.EstimatedDuration,
CreatedAt: dp.CreatedAt,
DryRun: dp.DryRun,
AppAction: dp.AppAction, // Struct copy is sufficient for this use case
}
// Deep copy instance actions
clone.InstanceActions = make([]InstanceAction, len(dp.InstanceActions))
copy(clone.InstanceActions, dp.InstanceActions)
return clone
}
// convertNetworkRules converts config network rules to EdgeConnect SecurityRules
func convertNetworkRules(network *config.NetworkConfig) []v2.SecurityRule {
rules := make([]v2.SecurityRule, len(network.OutboundConnections))
for i, conn := range network.OutboundConnections {
rules[i] = v2.SecurityRule{
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
}
return rules
}

View file

@ -1,166 +0,0 @@
// ABOUTME: Resource management for EdgeConnect delete command with deletion execution
// ABOUTME: Handles actual deletion operations with proper ordering (instances first, then app)
package v1
import (
"context"
"fmt"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
)
// ResourceManagerInterface defines the interface for resource management
type ResourceManagerInterface interface {
// ExecuteDeletion executes a deletion plan
ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error)
}
// EdgeConnectResourceManager implements resource management for EdgeConnect
type EdgeConnectResourceManager struct {
client EdgeConnectClientInterface
logger Logger
}
// Logger interface for deletion logging
type Logger interface {
Printf(format string, v ...interface{})
}
// ResourceManagerOptions configures the resource manager behavior
type ResourceManagerOptions struct {
// Logger for deletion operations
Logger Logger
}
// DefaultResourceManagerOptions returns sensible defaults
func DefaultResourceManagerOptions() ResourceManagerOptions {
return ResourceManagerOptions{
Logger: nil,
}
}
// NewResourceManager creates a new EdgeConnect resource manager
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
options := DefaultResourceManagerOptions()
for _, opt := range opts {
opt(&options)
}
return &EdgeConnectResourceManager{
client: client,
logger: options.Logger,
}
}
// WithLogger sets a logger for deletion operations
func WithLogger(logger Logger) func(*ResourceManagerOptions) {
return func(opts *ResourceManagerOptions) {
opts.Logger = logger
}
}
// ExecuteDeletion executes a deletion plan
// Important: Instances must be deleted before the app
func (rm *EdgeConnectResourceManager) ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error) {
startTime := time.Now()
rm.logf("Starting deletion: %s", plan.ConfigName)
result := &DeletionResult{
Plan: plan,
Success: true,
CompletedActions: []DeletionActionResult{},
FailedActions: []DeletionActionResult{},
}
// If plan is empty, return success immediately
if plan.IsEmpty() {
rm.logf("No resources to delete")
result.Duration = time.Since(startTime)
return result, nil
}
// Step 1: Delete all instances first
for _, instance := range plan.InstancesToDelete {
actionStart := time.Now()
rm.logf("Deleting instance: %s", instance.Name)
instanceKey := edgeconnect.AppInstanceKey{
Organization: instance.Organization,
Name: instance.Name,
CloudletKey: edgeconnect.CloudletKey{
Organization: instance.CloudletOrg,
Name: instance.CloudletName,
},
}
err := rm.client.DeleteAppInstance(ctx, instanceKey, instance.Region)
actionResult := DeletionActionResult{
Type: "instance",
Target: instance.Name,
Duration: time.Since(actionStart),
}
if err != nil {
rm.logf("Failed to delete instance %s: %v", instance.Name, err)
actionResult.Success = false
actionResult.Error = err
result.FailedActions = append(result.FailedActions, actionResult)
result.Success = false
result.Error = fmt.Errorf("failed to delete instance %s: %w", instance.Name, err)
result.Duration = time.Since(startTime)
return result, result.Error
}
rm.logf("Successfully deleted instance: %s", instance.Name)
actionResult.Success = true
result.CompletedActions = append(result.CompletedActions, actionResult)
}
// Step 2: Delete the app (only after all instances are deleted)
if plan.AppToDelete != nil {
actionStart := time.Now()
app := plan.AppToDelete
rm.logf("Deleting app: %s version %s", app.Name, app.Version)
appKey := edgeconnect.AppKey{
Organization: app.Organization,
Name: app.Name,
Version: app.Version,
}
err := rm.client.DeleteApp(ctx, appKey, app.Region)
actionResult := DeletionActionResult{
Type: "app",
Target: fmt.Sprintf("%s:%s", app.Name, app.Version),
Duration: time.Since(actionStart),
}
if err != nil {
rm.logf("Failed to delete app %s: %v", app.Name, err)
actionResult.Success = false
actionResult.Error = err
result.FailedActions = append(result.FailedActions, actionResult)
result.Success = false
result.Error = fmt.Errorf("failed to delete app %s: %w", app.Name, err)
result.Duration = time.Since(startTime)
return result, result.Error
}
rm.logf("Successfully deleted app: %s", app.Name)
actionResult.Success = true
result.CompletedActions = append(result.CompletedActions, actionResult)
}
result.Duration = time.Since(startTime)
rm.logf("Deletion completed successfully in %v", result.Duration)
return result, nil
}
// logf logs a message if a logger is configured
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
if rm.logger != nil {
rm.logger.Printf(format, v...)
}
}

View file

@ -1,229 +0,0 @@
// ABOUTME: Deletion planner for EdgeConnect delete command
// ABOUTME: Analyzes current state to identify resources for deletion
package v1
import (
"context"
"fmt"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
)
// EdgeConnectClientInterface defines the methods needed for deletion planning
type EdgeConnectClientInterface interface {
ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, 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
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error
}
// Planner defines the interface for deletion planning
type Planner interface {
// Plan analyzes the configuration and current state to generate a deletion plan
Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error)
// PlanWithOptions allows customization of planning behavior
PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error)
}
// PlanOptions provides configuration for the planning process
type PlanOptions struct {
// DryRun indicates this is a planning-only operation
DryRun bool
// Timeout for API operations
Timeout time.Duration
}
// DefaultPlanOptions returns sensible default planning options
func DefaultPlanOptions() PlanOptions {
return PlanOptions{
DryRun: false,
Timeout: 30 * time.Second,
}
}
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
type EdgeConnectPlanner struct {
client EdgeConnectClientInterface
}
// NewPlanner creates a new EdgeConnect deletion planner
func NewPlanner(client EdgeConnectClientInterface) Planner {
return &EdgeConnectPlanner{
client: client,
}
}
// Plan analyzes the configuration and generates a deletion plan
func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) {
return p.PlanWithOptions(ctx, config, DefaultPlanOptions())
}
// PlanWithOptions generates a deletion plan with custom options
func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) {
startTime := time.Now()
var warnings []string
// Create the deletion plan structure
plan := &DeletionPlan{
ConfigName: config.Metadata.Name,
CreatedAt: startTime,
DryRun: opts.DryRun,
}
// Get the region from the first infra template
region := config.Spec.InfraTemplate[0].Region
// Step 1: Check if instances exist
instancesResult := p.findInstancesToDelete(ctx, config, region)
plan.InstancesToDelete = instancesResult.instances
if instancesResult.err != nil {
warnings = append(warnings, fmt.Sprintf("Error querying instances: %v", instancesResult.err))
}
// Step 2: Check if app exists
appResult := p.findAppToDelete(ctx, config, region)
plan.AppToDelete = appResult.app
if appResult.err != nil && !isNotFoundError(appResult.err) {
warnings = append(warnings, fmt.Sprintf("Error querying app: %v", appResult.err))
}
// Step 3: Calculate plan metadata
p.calculatePlanMetadata(plan)
// Step 4: Generate summary
plan.Summary = plan.GenerateSummary()
return &PlanResult{
Plan: plan,
Warnings: warnings,
}, nil
}
type appQueryResult struct {
app *AppDeletion
err error
}
type instancesQueryResult struct {
instances []InstanceDeletion
err error
}
// findAppToDelete checks if the app exists and should be deleted
func (p *EdgeConnectPlanner) findAppToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) appQueryResult {
appKey := edgeconnect.AppKey{
Organization: config.Metadata.Organization,
Name: config.Metadata.Name,
Version: config.Metadata.AppVersion,
}
app, err := p.client.ShowApp(ctx, appKey, region)
if err != nil {
if isNotFoundError(err) {
return appQueryResult{app: nil, err: nil}
}
return appQueryResult{app: nil, err: err}
}
return appQueryResult{
app: &AppDeletion{
Name: app.Key.Name,
Version: app.Key.Version,
Organization: app.Key.Organization,
Region: region,
},
err: nil,
}
}
// findInstancesToDelete finds all instances that match the config
func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) instancesQueryResult {
var allInstances []InstanceDeletion
// Query instances for each infra template
for _, infra := range config.Spec.InfraTemplate {
instanceKey := edgeconnect.AppInstanceKey{
Organization: config.Metadata.Organization,
Name: generateInstanceName(config.Metadata.Name, config.Metadata.AppVersion),
CloudletKey: edgeconnect.CloudletKey{
Organization: infra.CloudletOrg,
Name: infra.CloudletName,
},
}
appKey := edgeconnect.AppKey{Name: config.Metadata.Name}
instances, err := p.client.ShowAppInstances(ctx, instanceKey, appKey, infra.Region)
if err != nil {
// If it's a not found error, just continue
if isNotFoundError(err) {
continue
}
return instancesQueryResult{instances: nil, err: err}
}
// Add found instances to the list
for _, inst := range instances {
allInstances = append(allInstances, InstanceDeletion{
Name: inst.Key.Name,
Organization: inst.Key.Organization,
Region: infra.Region,
CloudletOrg: inst.Key.CloudletKey.Organization,
CloudletName: inst.Key.CloudletKey.Name,
})
}
}
return instancesQueryResult{
instances: allInstances,
err: nil,
}
}
// calculatePlanMetadata calculates the total actions and estimated duration
func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeletionPlan) {
totalActions := 0
if plan.AppToDelete != nil {
totalActions++
}
totalActions += len(plan.InstancesToDelete)
plan.TotalActions = totalActions
// Estimate duration: ~5 seconds per instance, ~3 seconds for app
estimatedSeconds := len(plan.InstancesToDelete) * 5
if plan.AppToDelete != nil {
estimatedSeconds += 3
}
plan.EstimatedDuration = time.Duration(estimatedSeconds) * time.Second
}
// generateInstanceName creates an instance name from app name and version
func generateInstanceName(appName, appVersion string) string {
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
}
// isNotFoundError checks if an error is a 404 not found error
func isNotFoundError(err error) bool {
if apiErr, ok := err.(*edgeconnect.APIError); ok {
return apiErr.StatusCode == 404
}
return false
}
// PlanResult represents the result of a deletion planning operation
type PlanResult struct {
// Plan is the generated deletion plan
Plan *DeletionPlan
// Error if planning failed
Error error
// Warnings encountered during planning
Warnings []string
}

View file

@ -1,157 +0,0 @@
// ABOUTME: Deletion planning types for EdgeConnect delete command
// ABOUTME: Defines structures for deletion plans and deletion results
package v1
import (
"fmt"
"strings"
"time"
)
// DeletionPlan represents the complete deletion plan for a configuration
type DeletionPlan struct {
// ConfigName is the name from metadata
ConfigName string
// AppToDelete defines the app that will be deleted (nil if app doesn't exist)
AppToDelete *AppDeletion
// InstancesToDelete defines the instances that will be deleted
InstancesToDelete []InstanceDeletion
// Summary provides a human-readable summary of the plan
Summary string
// TotalActions is the count of all actions that will be performed
TotalActions int
// EstimatedDuration is the estimated time to complete the deletion
EstimatedDuration time.Duration
// CreatedAt timestamp when the plan was created
CreatedAt time.Time
// DryRun indicates if this is a dry-run plan
DryRun bool
}
// AppDeletion represents an application to be deleted
type AppDeletion struct {
// Name of the application
Name string
// Version of the application
Version string
// Organization that owns the app
Organization string
// Region where the app is deployed
Region string
}
// InstanceDeletion represents an application instance to be deleted
type InstanceDeletion struct {
// Name of the instance
Name string
// Organization that owns the instance
Organization string
// Region where the instance is deployed
Region string
// CloudletOrg that hosts the cloudlet
CloudletOrg string
// CloudletName where the instance is running
CloudletName string
}
// DeletionResult represents the result of a deletion operation
type DeletionResult struct {
// Plan that was executed
Plan *DeletionPlan
// Success indicates if the deletion was successful
Success bool
// CompletedActions lists actions that were successfully completed
CompletedActions []DeletionActionResult
// FailedActions lists actions that failed
FailedActions []DeletionActionResult
// Error that caused the deletion to fail (if any)
Error error
// Duration taken to execute the plan
Duration time.Duration
}
// DeletionActionResult represents the result of executing a single deletion action
type DeletionActionResult struct {
// Type of resource that was deleted ("app" or "instance")
Type string
// Target describes what was being deleted
Target string
// Success indicates if the action succeeded
Success bool
// Error if the action failed
Error error
// Duration taken to complete the action
Duration time.Duration
}
// IsEmpty returns true if the deletion plan has no actions to perform
func (dp *DeletionPlan) IsEmpty() bool {
return dp.AppToDelete == nil && len(dp.InstancesToDelete) == 0
}
// GenerateSummary creates a human-readable summary of the deletion plan
func (dp *DeletionPlan) GenerateSummary() string {
if dp.IsEmpty() {
return "No resources found to delete"
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Deletion plan for '%s':\n", dp.ConfigName))
// Instance actions
if len(dp.InstancesToDelete) > 0 {
sb.WriteString(fmt.Sprintf("- DELETE %d instance(s)\n", len(dp.InstancesToDelete)))
cloudletSet := make(map[string]bool)
for _, inst := range dp.InstancesToDelete {
key := fmt.Sprintf("%s:%s", inst.CloudletOrg, inst.CloudletName)
cloudletSet[key] = true
}
sb.WriteString(fmt.Sprintf(" Across %d cloudlet(s)\n", len(cloudletSet)))
}
// App action
if dp.AppToDelete != nil {
sb.WriteString(fmt.Sprintf("- DELETE application '%s' version %s\n",
dp.AppToDelete.Name, dp.AppToDelete.Version))
}
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
return sb.String()
}
// Validate checks if the deletion plan is valid
func (dp *DeletionPlan) Validate() error {
if dp.ConfigName == "" {
return fmt.Errorf("deletion plan must have a config name")
}
if dp.IsEmpty() {
return fmt.Errorf("deletion plan has no resources to delete")
}
return nil
}

View file

@ -1,166 +0,0 @@
// ABOUTME: Resource management for EdgeConnect delete command with deletion execution
// ABOUTME: Handles actual deletion operations with proper ordering (instances first, then app)
package v2
import (
"context"
"fmt"
"time"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
)
// ResourceManagerInterface defines the interface for resource management
type ResourceManagerInterface interface {
// ExecuteDeletion executes a deletion plan
ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error)
}
// EdgeConnectResourceManager implements resource management for EdgeConnect
type EdgeConnectResourceManager struct {
client EdgeConnectClientInterface
logger Logger
}
// Logger interface for deletion logging
type Logger interface {
Printf(format string, v ...interface{})
}
// ResourceManagerOptions configures the resource manager behavior
type ResourceManagerOptions struct {
// Logger for deletion operations
Logger Logger
}
// DefaultResourceManagerOptions returns sensible defaults
func DefaultResourceManagerOptions() ResourceManagerOptions {
return ResourceManagerOptions{
Logger: nil,
}
}
// NewResourceManager creates a new EdgeConnect resource manager
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
options := DefaultResourceManagerOptions()
for _, opt := range opts {
opt(&options)
}
return &EdgeConnectResourceManager{
client: client,
logger: options.Logger,
}
}
// WithLogger sets a logger for deletion operations
func WithLogger(logger Logger) func(*ResourceManagerOptions) {
return func(opts *ResourceManagerOptions) {
opts.Logger = logger
}
}
// ExecuteDeletion executes a deletion plan
// Important: Instances must be deleted before the app
func (rm *EdgeConnectResourceManager) ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error) {
startTime := time.Now()
rm.logf("Starting deletion: %s", plan.ConfigName)
result := &DeletionResult{
Plan: plan,
Success: true,
CompletedActions: []DeletionActionResult{},
FailedActions: []DeletionActionResult{},
}
// If plan is empty, return success immediately
if plan.IsEmpty() {
rm.logf("No resources to delete")
result.Duration = time.Since(startTime)
return result, nil
}
// Step 1: Delete all instances first
for _, instance := range plan.InstancesToDelete {
actionStart := time.Now()
rm.logf("Deleting instance: %s", instance.Name)
instanceKey := v2.AppInstanceKey{
Organization: instance.Organization,
Name: instance.Name,
CloudletKey: v2.CloudletKey{
Organization: instance.CloudletOrg,
Name: instance.CloudletName,
},
}
err := rm.client.DeleteAppInstance(ctx, instanceKey, instance.Region)
actionResult := DeletionActionResult{
Type: "instance",
Target: instance.Name,
Duration: time.Since(actionStart),
}
if err != nil {
rm.logf("Failed to delete instance %s: %v", instance.Name, err)
actionResult.Success = false
actionResult.Error = err
result.FailedActions = append(result.FailedActions, actionResult)
result.Success = false
result.Error = fmt.Errorf("failed to delete instance %s: %w", instance.Name, err)
result.Duration = time.Since(startTime)
return result, result.Error
}
rm.logf("Successfully deleted instance: %s", instance.Name)
actionResult.Success = true
result.CompletedActions = append(result.CompletedActions, actionResult)
}
// Step 2: Delete the app (only after all instances are deleted)
if plan.AppToDelete != nil {
actionStart := time.Now()
app := plan.AppToDelete
rm.logf("Deleting app: %s version %s", app.Name, app.Version)
appKey := v2.AppKey{
Organization: app.Organization,
Name: app.Name,
Version: app.Version,
}
err := rm.client.DeleteApp(ctx, appKey, app.Region)
actionResult := DeletionActionResult{
Type: "app",
Target: fmt.Sprintf("%s:%s", app.Name, app.Version),
Duration: time.Since(actionStart),
}
if err != nil {
rm.logf("Failed to delete app %s: %v", app.Name, err)
actionResult.Success = false
actionResult.Error = err
result.FailedActions = append(result.FailedActions, actionResult)
result.Success = false
result.Error = fmt.Errorf("failed to delete app %s: %w", app.Name, err)
result.Duration = time.Since(startTime)
return result, result.Error
}
rm.logf("Successfully deleted app: %s", app.Name)
actionResult.Success = true
result.CompletedActions = append(result.CompletedActions, actionResult)
}
result.Duration = time.Since(startTime)
rm.logf("Deletion completed successfully in %v", result.Duration)
return result, nil
}
// logf logs a message if a logger is configured
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
if rm.logger != nil {
rm.logger.Printf(format, v...)
}
}

View file

@ -1,200 +0,0 @@
// ABOUTME: Tests for EdgeConnect deletion manager with mock scenarios
// ABOUTME: Tests deletion execution and error handling with mock clients
package v2
import (
"context"
"fmt"
"testing"
"time"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// MockResourceClient for testing deletion manager
type MockResourceClient struct {
mock.Mock
}
func (m *MockResourceClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) {
args := m.Called(ctx, appKey, region)
if args.Get(0) == nil {
return v2.App{}, args.Error(1)
}
return args.Get(0).(v2.App), args.Error(1)
}
func (m *MockResourceClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) {
args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]v2.AppInstance), args.Error(1)
}
func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
args := m.Called(ctx, appKey, region)
return args.Error(0)
}
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
args := m.Called(ctx, instanceKey, region)
return args.Error(0)
}
// TestLogger implements Logger interface for testing
type TestLogger struct {
messages []string
}
func (l *TestLogger) Printf(format string, v ...interface{}) {
l.messages = append(l.messages, fmt.Sprintf(format, v...))
}
func TestNewResourceManager(t *testing.T) {
mockClient := &MockResourceClient{}
manager := NewResourceManager(mockClient)
assert.NotNil(t, manager)
}
func TestWithLogger(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger))
// Cast to implementation to check logger was set
impl := manager.(*EdgeConnectResourceManager)
assert.Equal(t, logger, impl.logger)
}
func createTestDeletionPlan() *DeletionPlan {
return &DeletionPlan{
ConfigName: "test-deletion",
AppToDelete: &AppDeletion{
Name: "test-app",
Version: "1.0.0",
Organization: "testorg",
Region: "US",
},
InstancesToDelete: []InstanceDeletion{
{
Name: "test-app-1.0.0-instance",
Organization: "testorg",
Region: "US",
CloudletOrg: "cloudletorg",
CloudletName: "cloudlet1",
},
},
TotalActions: 2,
EstimatedDuration: 10 * time.Second,
}
}
func TestExecuteDeletion_Success(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger))
plan := createTestDeletionPlan()
// Mock successful deletion operations
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(nil)
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(nil)
ctx := context.Background()
result, err := manager.ExecuteDeletion(ctx, plan)
require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.Success)
assert.Len(t, result.CompletedActions, 2) // 1 instance + 1 app
assert.Len(t, result.FailedActions, 0)
mockClient.AssertExpectations(t)
}
func TestExecuteDeletion_InstanceDeleteFails(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger))
plan := createTestDeletionPlan()
// Mock instance deletion failure
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(fmt.Errorf("instance deletion failed"))
ctx := context.Background()
result, err := manager.ExecuteDeletion(ctx, plan)
require.Error(t, err)
require.NotNil(t, result)
assert.False(t, result.Success)
assert.Len(t, result.FailedActions, 1)
mockClient.AssertExpectations(t)
}
func TestExecuteDeletion_OnlyInstances(t *testing.T) {
mockClient := &MockResourceClient{}
logger := &TestLogger{}
manager := NewResourceManager(mockClient, WithLogger(logger))
plan := &DeletionPlan{
ConfigName: "test-deletion",
AppToDelete: nil, // No app to delete
InstancesToDelete: []InstanceDeletion{
{
Name: "test-app-1.0.0-instance",
Organization: "testorg",
Region: "US",
CloudletOrg: "cloudletorg",
CloudletName: "cloudlet1",
},
},
TotalActions: 1,
EstimatedDuration: 5 * time.Second,
}
// Mock successful instance deletion
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(nil)
ctx := context.Background()
result, err := manager.ExecuteDeletion(ctx, plan)
require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.Success)
assert.Len(t, result.CompletedActions, 1)
mockClient.AssertExpectations(t)
}
func TestExecuteDeletion_EmptyPlan(t *testing.T) {
mockClient := &MockResourceClient{}
manager := NewResourceManager(mockClient)
plan := &DeletionPlan{
ConfigName: "test-deletion",
AppToDelete: nil,
InstancesToDelete: []InstanceDeletion{},
TotalActions: 0,
}
ctx := context.Background()
result, err := manager.ExecuteDeletion(ctx, plan)
require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.Success)
assert.Len(t, result.CompletedActions, 0)
assert.Len(t, result.FailedActions, 0)
}

View file

@ -1,229 +0,0 @@
// ABOUTME: Deletion planner for EdgeConnect delete command
// ABOUTME: Analyzes current state to identify resources for deletion
package v2
import (
"context"
"fmt"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
)
// EdgeConnectClientInterface defines the methods needed for deletion planning
type EdgeConnectClientInterface interface {
ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, 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
DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error
}
// Planner defines the interface for deletion planning
type Planner interface {
// Plan analyzes the configuration and current state to generate a deletion plan
Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error)
// PlanWithOptions allows customization of planning behavior
PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error)
}
// PlanOptions provides configuration for the planning process
type PlanOptions struct {
// DryRun indicates this is a planning-only operation
DryRun bool
// Timeout for API operations
Timeout time.Duration
}
// DefaultPlanOptions returns sensible default planning options
func DefaultPlanOptions() PlanOptions {
return PlanOptions{
DryRun: false,
Timeout: 30 * time.Second,
}
}
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
type EdgeConnectPlanner struct {
client EdgeConnectClientInterface
}
// NewPlanner creates a new EdgeConnect deletion planner
func NewPlanner(client EdgeConnectClientInterface) Planner {
return &EdgeConnectPlanner{
client: client,
}
}
// Plan analyzes the configuration and generates a deletion plan
func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) {
return p.PlanWithOptions(ctx, config, DefaultPlanOptions())
}
// PlanWithOptions generates a deletion plan with custom options
func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) {
startTime := time.Now()
var warnings []string
// Create the deletion plan structure
plan := &DeletionPlan{
ConfigName: config.Metadata.Name,
CreatedAt: startTime,
DryRun: opts.DryRun,
}
// Get the region from the first infra template
region := config.Spec.InfraTemplate[0].Region
// Step 1: Check if instances exist
instancesResult := p.findInstancesToDelete(ctx, config, region)
plan.InstancesToDelete = instancesResult.instances
if instancesResult.err != nil {
warnings = append(warnings, fmt.Sprintf("Error querying instances: %v", instancesResult.err))
}
// Step 2: Check if app exists
appResult := p.findAppToDelete(ctx, config, region)
plan.AppToDelete = appResult.app
if appResult.err != nil && !isNotFoundError(appResult.err) {
warnings = append(warnings, fmt.Sprintf("Error querying app: %v", appResult.err))
}
// Step 3: Calculate plan metadata
p.calculatePlanMetadata(plan)
// Step 4: Generate summary
plan.Summary = plan.GenerateSummary()
return &PlanResult{
Plan: plan,
Warnings: warnings,
}, nil
}
type appQueryResult struct {
app *AppDeletion
err error
}
type instancesQueryResult struct {
instances []InstanceDeletion
err error
}
// findAppToDelete checks if the app exists and should be deleted
func (p *EdgeConnectPlanner) findAppToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) appQueryResult {
appKey := v2.AppKey{
Organization: config.Metadata.Organization,
Name: config.Metadata.Name,
Version: config.Metadata.AppVersion,
}
app, err := p.client.ShowApp(ctx, appKey, region)
if err != nil {
if isNotFoundError(err) {
return appQueryResult{app: nil, err: nil}
}
return appQueryResult{app: nil, err: err}
}
return appQueryResult{
app: &AppDeletion{
Name: app.Key.Name,
Version: app.Key.Version,
Organization: app.Key.Organization,
Region: region,
},
err: nil,
}
}
// findInstancesToDelete finds all instances that match the config
func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) instancesQueryResult {
var allInstances []InstanceDeletion
// Query instances for each infra template
for _, infra := range config.Spec.InfraTemplate {
instanceKey := v2.AppInstanceKey{
Organization: config.Metadata.Organization,
Name: generateInstanceName(config.Metadata.Name, config.Metadata.AppVersion),
CloudletKey: v2.CloudletKey{
Organization: infra.CloudletOrg,
Name: infra.CloudletName,
},
}
appKey := v2.AppKey{Name: config.Metadata.Name}
instances, err := p.client.ShowAppInstances(ctx, instanceKey, appKey, infra.Region)
if err != nil {
// If it's a not found error, just continue
if isNotFoundError(err) {
continue
}
return instancesQueryResult{instances: nil, err: err}
}
// Add found instances to the list
for _, inst := range instances {
allInstances = append(allInstances, InstanceDeletion{
Name: inst.Key.Name,
Organization: inst.Key.Organization,
Region: infra.Region,
CloudletOrg: inst.Key.CloudletKey.Organization,
CloudletName: inst.Key.CloudletKey.Name,
})
}
}
return instancesQueryResult{
instances: allInstances,
err: nil,
}
}
// calculatePlanMetadata calculates the total actions and estimated duration
func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeletionPlan) {
totalActions := 0
if plan.AppToDelete != nil {
totalActions++
}
totalActions += len(plan.InstancesToDelete)
plan.TotalActions = totalActions
// Estimate duration: ~5 seconds per instance, ~3 seconds for app
estimatedSeconds := len(plan.InstancesToDelete) * 5
if plan.AppToDelete != nil {
estimatedSeconds += 3
}
plan.EstimatedDuration = time.Duration(estimatedSeconds) * time.Second
}
// generateInstanceName creates an instance name from app name and version
func generateInstanceName(appName, appVersion string) string {
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
}
// isNotFoundError checks if an error is a 404 not found error
func isNotFoundError(err error) bool {
if apiErr, ok := err.(*v2.APIError); ok {
return apiErr.StatusCode == 404
}
return false
}
// PlanResult represents the result of a deletion planning operation
type PlanResult struct {
// Plan is the generated deletion plan
Plan *DeletionPlan
// Error if planning failed
Error error
// Warnings encountered during planning
Warnings []string
}

View file

@ -1,219 +0,0 @@
// ABOUTME: Tests for EdgeConnect deletion planner with mock scenarios
// ABOUTME: Tests deletion planning logic and resource discovery
package v2
import (
"context"
"os"
"path/filepath"
"testing"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// MockEdgeConnectClient is a mock implementation of the EdgeConnect client
type MockEdgeConnectClient struct {
mock.Mock
}
func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) {
args := m.Called(ctx, appKey, region)
if args.Get(0) == nil {
return v2.App{}, args.Error(1)
}
return args.Get(0).(v2.App), args.Error(1)
}
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) {
args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]v2.AppInstance), args.Error(1)
}
func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
args := m.Called(ctx, appKey, region)
return args.Error(0)
}
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
args := m.Called(ctx, instanceKey, region)
return args.Error(0)
}
func createTestConfig(t *testing.T) *config.EdgeConnectConfig {
// Create temporary manifest file
tempDir := t.TempDir()
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
require.NoError(t, err)
return &config.EdgeConnectConfig{
Kind: "edgeconnect-deployment",
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "testorg",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{
ManifestFile: manifestFile,
},
InfraTemplate: []config.InfraTemplate{
{
Region: "US",
CloudletOrg: "TestCloudletOrg",
CloudletName: "TestCloudlet",
FlavorName: "small",
},
},
},
}
}
func TestNewPlanner(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
assert.NotNil(t, planner)
}
func TestPlanDeletion_WithExistingResources(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Mock existing app
existingApp := v2.App{
Key: v2.AppKey{
Organization: "testorg",
Name: "test-app",
Version: "1.0.0",
},
Deployment: "kubernetes",
}
// Mock existing instances
existingInstances := []v2.AppInstance{
{
Key: v2.AppInstanceKey{
Organization: "testorg",
Name: "test-app-1.0.0-instance",
CloudletKey: v2.CloudletKey{
Organization: "TestCloudletOrg",
Name: "TestCloudlet",
},
},
AppKey: v2.AppKey{
Organization: "testorg",
Name: "test-app",
Version: "1.0.0",
},
},
}
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(existingApp, nil)
mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(existingInstances, nil)
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.Equal(t, "test-app", plan.ConfigName)
assert.NotNil(t, plan.AppToDelete)
assert.Equal(t, "test-app", plan.AppToDelete.Name)
assert.Equal(t, "1.0.0", plan.AppToDelete.Version)
assert.Equal(t, "testorg", plan.AppToDelete.Organization)
require.Len(t, plan.InstancesToDelete, 1)
assert.Equal(t, "test-app-1.0.0-instance", plan.InstancesToDelete[0].Name)
assert.Equal(t, "testorg", plan.InstancesToDelete[0].Organization)
assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance
assert.False(t, plan.IsEmpty())
mockClient.AssertExpectations(t)
}
func TestPlanDeletion_NoResourcesExist(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Mock API calls to return "not found" errors
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(v2.App{}, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return([]v2.AppInstance{}, nil)
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.Equal(t, "test-app", plan.ConfigName)
assert.Nil(t, plan.AppToDelete)
assert.Len(t, plan.InstancesToDelete, 0)
assert.Equal(t, 0, plan.TotalActions)
assert.True(t, plan.IsEmpty())
mockClient.AssertExpectations(t)
}
func TestPlanDeletion_OnlyInstancesExist(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
testConfig := createTestConfig(t)
// Mock existing instances but no app
existingInstances := []v2.AppInstance{
{
Key: v2.AppInstanceKey{
Organization: "testorg",
Name: "test-app-1.0.0-instance",
CloudletKey: v2.CloudletKey{
Organization: "TestCloudletOrg",
Name: "TestCloudlet",
},
},
},
}
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
Return(v2.App{}, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
Return(existingInstances, nil)
ctx := context.Background()
result, err := planner.Plan(ctx, testConfig)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Plan)
plan := result.Plan
assert.Nil(t, plan.AppToDelete)
assert.Len(t, plan.InstancesToDelete, 1)
assert.Equal(t, 1, plan.TotalActions)
assert.False(t, plan.IsEmpty())
mockClient.AssertExpectations(t)
}

View file

@ -1,157 +0,0 @@
// ABOUTME: Deletion planning types for EdgeConnect delete command
// ABOUTME: Defines structures for deletion plans and deletion results
package v2
import (
"fmt"
"strings"
"time"
)
// DeletionPlan represents the complete deletion plan for a configuration
type DeletionPlan struct {
// ConfigName is the name from metadata
ConfigName string
// AppToDelete defines the app that will be deleted (nil if app doesn't exist)
AppToDelete *AppDeletion
// InstancesToDelete defines the instances that will be deleted
InstancesToDelete []InstanceDeletion
// Summary provides a human-readable summary of the plan
Summary string
// TotalActions is the count of all actions that will be performed
TotalActions int
// EstimatedDuration is the estimated time to complete the deletion
EstimatedDuration time.Duration
// CreatedAt timestamp when the plan was created
CreatedAt time.Time
// DryRun indicates if this is a dry-run plan
DryRun bool
}
// AppDeletion represents an application to be deleted
type AppDeletion struct {
// Name of the application
Name string
// Version of the application
Version string
// Organization that owns the app
Organization string
// Region where the app is deployed
Region string
}
// InstanceDeletion represents an application instance to be deleted
type InstanceDeletion struct {
// Name of the instance
Name string
// Organization that owns the instance
Organization string
// Region where the instance is deployed
Region string
// CloudletOrg that hosts the cloudlet
CloudletOrg string
// CloudletName where the instance is running
CloudletName string
}
// DeletionResult represents the result of a deletion operation
type DeletionResult struct {
// Plan that was executed
Plan *DeletionPlan
// Success indicates if the deletion was successful
Success bool
// CompletedActions lists actions that were successfully completed
CompletedActions []DeletionActionResult
// FailedActions lists actions that failed
FailedActions []DeletionActionResult
// Error that caused the deletion to fail (if any)
Error error
// Duration taken to execute the plan
Duration time.Duration
}
// DeletionActionResult represents the result of executing a single deletion action
type DeletionActionResult struct {
// Type of resource that was deleted ("app" or "instance")
Type string
// Target describes what was being deleted
Target string
// Success indicates if the action succeeded
Success bool
// Error if the action failed
Error error
// Duration taken to complete the action
Duration time.Duration
}
// IsEmpty returns true if the deletion plan has no actions to perform
func (dp *DeletionPlan) IsEmpty() bool {
return dp.AppToDelete == nil && len(dp.InstancesToDelete) == 0
}
// GenerateSummary creates a human-readable summary of the deletion plan
func (dp *DeletionPlan) GenerateSummary() string {
if dp.IsEmpty() {
return "No resources found to delete"
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Deletion plan for '%s':\n", dp.ConfigName))
// Instance actions
if len(dp.InstancesToDelete) > 0 {
sb.WriteString(fmt.Sprintf("- DELETE %d instance(s)\n", len(dp.InstancesToDelete)))
cloudletSet := make(map[string]bool)
for _, inst := range dp.InstancesToDelete {
key := fmt.Sprintf("%s:%s", inst.CloudletOrg, inst.CloudletName)
cloudletSet[key] = true
}
sb.WriteString(fmt.Sprintf(" Across %d cloudlet(s)\n", len(cloudletSet)))
}
// App action
if dp.AppToDelete != nil {
sb.WriteString(fmt.Sprintf("- DELETE application '%s' version %s\n",
dp.AppToDelete.Name, dp.AppToDelete.Version))
}
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
return sb.String()
}
// Validate checks if the deletion plan is valid
func (dp *DeletionPlan) Validate() error {
if dp.ConfigName == "" {
return fmt.Errorf("deletion plan must have a config name")
}
if dp.IsEmpty() {
return fmt.Errorf("deletion plan has no resources to delete")
}
return nil
}

View file

@ -1,95 +0,0 @@
package v2
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestDeletionPlan_IsEmpty(t *testing.T) {
tests := []struct {
name string
plan *DeletionPlan
expected bool
}{
{
name: "empty plan with no resources",
plan: &DeletionPlan{
ConfigName: "test-config",
AppToDelete: nil,
InstancesToDelete: []InstanceDeletion{},
},
expected: true,
},
{
name: "plan with app deletion",
plan: &DeletionPlan{
ConfigName: "test-config",
AppToDelete: &AppDeletion{
Name: "test-app",
Organization: "test-org",
Version: "1.0",
Region: "US",
},
InstancesToDelete: []InstanceDeletion{},
},
expected: false,
},
{
name: "plan with instance deletion",
plan: &DeletionPlan{
ConfigName: "test-config",
AppToDelete: nil,
InstancesToDelete: []InstanceDeletion{
{
Name: "test-instance",
Organization: "test-org",
},
},
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.plan.IsEmpty()
assert.Equal(t, tt.expected, result)
})
}
}
func TestDeletionPlan_GenerateSummary(t *testing.T) {
plan := &DeletionPlan{
ConfigName: "test-config",
AppToDelete: &AppDeletion{
Name: "test-app",
Organization: "test-org",
Version: "1.0",
Region: "US",
},
InstancesToDelete: []InstanceDeletion{
{
Name: "test-instance-1",
Organization: "test-org",
CloudletName: "cloudlet-1",
CloudletOrg: "cloudlet-org",
},
{
Name: "test-instance-2",
Organization: "test-org",
CloudletName: "cloudlet-2",
CloudletOrg: "cloudlet-org",
},
},
TotalActions: 3,
EstimatedDuration: 30 * time.Second,
}
summary := plan.GenerateSummary()
assert.Contains(t, summary, "test-config")
assert.Contains(t, summary, "DELETE application 'test-app'")
assert.Contains(t, summary, "DELETE 2 instance(s)")
}

31
internal/domain/app.go Normal file
View file

@ -0,0 +1,31 @@
package domain
// AppKey uniquely identifies an application
type AppKey struct {
Organization string
Name string
Version string
}
// App represents an application definition
type App struct {
Key AppKey
Deployment string
ImageType string
ImagePath string
AllowServerless bool
DefaultFlavor Flavor
ServerlessConfig interface{}
DeploymentGenerator string
DeploymentManifest string
RequiredOutboundConnections []SecurityRule
Fields []string
}
// SecurityRule defines network access rules
type SecurityRule struct {
PortRangeMax int
PortRangeMin int
Protocol string
RemoteCIDR string
}

View file

@ -0,0 +1,18 @@
package domain
// AppInstanceKey uniquely identifies an application instance
type AppInstanceKey struct {
Organization string
Name string
CloudletKey CloudletKey
}
// AppInstance represents a deployed application instance
type AppInstance struct {
Key AppInstanceKey
AppKey AppKey
Flavor Flavor
State string
PowerState string
Fields []string
}

View file

@ -0,0 +1,26 @@
package domain
// CloudletKey uniquely identifies a cloudlet
type CloudletKey struct {
Organization string
Name string
}
// Cloudlet represents edge infrastructure
type Cloudlet struct {
Key CloudletKey
Location Location
IpSupport string
NumDynamicIps int32
State string
Flavor Flavor
PhysicalName string
Region string
NotifySrvAddr string
}
// Location represents geographical coordinates
type Location struct {
Latitude float64
Longitude float64
}

309
internal/domain/errors.go Normal file
View file

@ -0,0 +1,309 @@
// Package domain contains domain-specific error types for the EdgeConnect client
package domain
import (
"errors"
"fmt"
"strings"
)
// ErrorCode represents different types of domain errors
type ErrorCode int
const (
// Resource errors
ErrResourceNotFound ErrorCode = iota
ErrResourceAlreadyExists
ErrResourceConflict
// Validation errors
ErrValidationFailed
ErrInvalidConfiguration
ErrInvalidInput
// Business logic errors
ErrQuotaExceeded
ErrInsufficientPermissions
ErrOperationNotAllowed
// Infrastructure errors
ErrNetworkError
ErrAuthenticationFailed
ErrServiceUnavailable
ErrTimeout
// Internal errors
ErrInternalError
ErrUnknownError
)
// String returns a human-readable string representation of the error code
func (e ErrorCode) String() string {
switch e {
case ErrResourceNotFound:
return "RESOURCE_NOT_FOUND"
case ErrResourceAlreadyExists:
return "RESOURCE_ALREADY_EXISTS"
case ErrResourceConflict:
return "RESOURCE_CONFLICT"
case ErrValidationFailed:
return "VALIDATION_FAILED"
case ErrInvalidConfiguration:
return "INVALID_CONFIGURATION"
case ErrInvalidInput:
return "INVALID_INPUT"
case ErrQuotaExceeded:
return "QUOTA_EXCEEDED"
case ErrInsufficientPermissions:
return "INSUFFICIENT_PERMISSIONS"
case ErrOperationNotAllowed:
return "OPERATION_NOT_ALLOWED"
case ErrNetworkError:
return "NETWORK_ERROR"
case ErrAuthenticationFailed:
return "AUTHENTICATION_FAILED"
case ErrServiceUnavailable:
return "SERVICE_UNAVAILABLE"
case ErrTimeout:
return "TIMEOUT"
case ErrInternalError:
return "INTERNAL_ERROR"
case ErrUnknownError:
return "UNKNOWN_ERROR"
default:
return "UNDEFINED_ERROR"
}
}
// DomainError represents a domain-specific error with detailed context
type DomainError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
Cause error `json:"-"`
Context map[string]interface{} `json:"context,omitempty"`
Resource *ResourceIdentifier `json:"resource,omitempty"`
Operation string `json:"operation,omitempty"`
Retryable bool `json:"retryable"`
}
// ResourceIdentifier provides context about the resource involved in the error
type ResourceIdentifier struct {
Type string `json:"type"`
Name string `json:"name"`
Organization string `json:"organization,omitempty"`
Region string `json:"region,omitempty"`
Version string `json:"version,omitempty"`
}
// Error implements the error interface
func (e *DomainError) Error() string {
var parts []string
if e.Operation != "" {
parts = append(parts, fmt.Sprintf("operation %s failed", e.Operation))
}
if e.Resource != nil {
parts = append(parts, fmt.Sprintf("resource %s", e.resourceString()))
}
parts = append(parts, e.Message)
if e.Details != "" {
parts = append(parts, e.Details)
}
result := strings.Join(parts, ": ")
if e.Cause != nil {
result = fmt.Sprintf("%s (caused by: %v)", result, e.Cause)
}
return result
}
// Unwrap returns the underlying cause for error wrapping
func (e *DomainError) Unwrap() error {
return e.Cause
}
// Is checks if the error matches a specific error code
func (e *DomainError) Is(target error) bool {
if de, ok := target.(*DomainError); ok {
return e.Code == de.Code
}
return false
}
// IsRetryable indicates whether the operation should be retried
func (e *DomainError) IsRetryable() bool {
return e.Retryable
}
// WithContext adds context information to the error
func (e *DomainError) WithContext(key string, value interface{}) *DomainError {
if e.Context == nil {
e.Context = make(map[string]interface{})
}
e.Context[key] = value
return e
}
// WithDetails adds additional details to the error
func (e *DomainError) WithDetails(details string) *DomainError {
e.Details = details
return e
}
func (e *DomainError) resourceString() string {
if e.Resource == nil {
return ""
}
parts := []string{e.Resource.Type}
if e.Resource.Organization != "" && e.Resource.Name != "" {
parts = append(parts, fmt.Sprintf("%s/%s", e.Resource.Organization, e.Resource.Name))
} else if e.Resource.Name != "" {
parts = append(parts, e.Resource.Name)
}
if e.Resource.Version != "" {
parts = append(parts, fmt.Sprintf("version %s", e.Resource.Version))
}
if e.Resource.Region != "" {
parts = append(parts, fmt.Sprintf("in region %s", e.Resource.Region))
}
return strings.Join(parts, " ")
}
// Error creation helpers
// NewDomainError creates a new domain error with the specified code and message
func NewDomainError(code ErrorCode, message string) *DomainError {
return &DomainError{
Code: code,
Message: message,
Retryable: isRetryableByDefault(code),
}
}
// NewDomainErrorWithCause creates a new domain error with an underlying cause
func NewDomainErrorWithCause(code ErrorCode, message string, cause error) *DomainError {
return &DomainError{
Code: code,
Message: message,
Cause: cause,
Retryable: isRetryableByDefault(code),
}
}
// NewResourceError creates a domain error for resource-related operations
func NewResourceError(code ErrorCode, operation string, resource *ResourceIdentifier, message string) *DomainError {
return &DomainError{
Code: code,
Message: message,
Operation: operation,
Resource: resource,
Retryable: isRetryableByDefault(code),
}
}
func isRetryableByDefault(code ErrorCode) bool {
switch code {
case ErrNetworkError, ErrServiceUnavailable, ErrTimeout, ErrInternalError:
return true
default:
return false
}
}
// Predefined errors for common scenarios
var (
// Resource errors
ErrAppNotFound = NewDomainError(ErrResourceNotFound, "application not found")
ErrAppExists = NewDomainError(ErrResourceAlreadyExists, "application already exists")
ErrInstanceNotFound = NewDomainError(ErrResourceNotFound, "app instance not found")
ErrInstanceExists = NewDomainError(ErrResourceAlreadyExists, "app instance already exists")
ErrCloudletNotFound = NewDomainError(ErrResourceNotFound, "cloudlet not found")
// Validation errors
ErrInvalidAppKey = NewDomainError(ErrValidationFailed, "invalid application key")
ErrInvalidInstanceKey = NewDomainError(ErrValidationFailed, "invalid app instance key")
ErrInvalidCloudletKey = NewDomainError(ErrValidationFailed, "invalid cloudlet key")
ErrMissingRegion = NewDomainError(ErrValidationFailed, "region is required")
// Business logic errors
ErrDeploymentFailed = NewDomainError(ErrOperationNotAllowed, "deployment failed")
ErrRollbackFailed = NewDomainError(ErrOperationNotAllowed, "rollback failed")
ErrPlanningFailed = NewDomainError(ErrOperationNotAllowed, "deployment planning failed")
)
// Helper functions for creating specific error scenarios
// NewAppError creates an error related to application operations
func NewAppError(code ErrorCode, operation string, appKey AppKey, region string, message string) *DomainError {
resource := &ResourceIdentifier{
Type: "app",
Organization: appKey.Organization,
Name: appKey.Name,
Version: appKey.Version,
Region: region,
}
return NewResourceError(code, operation, resource, message)
}
// NewInstanceError creates an error related to app instance operations
func NewInstanceError(code ErrorCode, operation string, instanceKey AppInstanceKey, region string, message string) *DomainError {
resource := &ResourceIdentifier{
Type: "app-instance",
Organization: instanceKey.Organization,
Name: instanceKey.Name,
Region: region,
}
return NewResourceError(code, operation, resource, message)
}
// NewCloudletError creates an error related to cloudlet operations
func NewCloudletError(code ErrorCode, operation string, cloudletKey CloudletKey, region string, message string) *DomainError {
resource := &ResourceIdentifier{
Type: "cloudlet",
Organization: cloudletKey.Organization,
Name: cloudletKey.Name,
Region: region,
}
return NewResourceError(code, operation, resource, message)
}
// Error checking utilities
// IsNotFoundError checks if an error indicates a resource was not found
func IsNotFoundError(err error) bool {
var de *DomainError
return errors.As(err, &de) && de.Code == ErrResourceNotFound
}
// IsValidationError checks if an error is a validation error
func IsValidationError(err error) bool {
var de *DomainError
return errors.As(err, &de) && (de.Code == ErrValidationFailed || de.Code == ErrInvalidInput || de.Code == ErrInvalidConfiguration)
}
// IsRetryableError checks if an error is retryable
func IsRetryableError(err error) bool {
var de *DomainError
if errors.As(err, &de) {
return de.IsRetryable()
}
return false
}
// IsAuthenticationError checks if an error is authentication-related
func IsAuthenticationError(err error) bool {
var de *DomainError
return errors.As(err, &de) && de.Code == ErrAuthenticationFailed
}

View file

@ -0,0 +1,207 @@
package domain
import (
"errors"
"testing"
)
func TestDomainError_Creation(t *testing.T) {
tests := []struct {
name string
code ErrorCode
message string
expected string
}{
{
name: "simple error",
code: ErrResourceNotFound,
message: "test resource not found",
expected: "test resource not found",
},
{
name: "validation error",
code: ErrValidationFailed,
message: "invalid input",
expected: "invalid input",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := NewDomainError(tt.code, tt.message)
if err.Error() != tt.expected {
t.Errorf("Expected error message %q, got %q", tt.expected, err.Error())
}
if err.Code != tt.code {
t.Errorf("Expected error code %v, got %v", tt.code, err.Code)
}
})
}
}
func TestDomainError_WithContext(t *testing.T) {
err := NewDomainError(ErrResourceNotFound, "test error")
err = err.WithContext("user_id", "123")
err = err.WithContext("operation", "create")
if len(err.Context) != 2 {
t.Errorf("Expected 2 context items, got %d", len(err.Context))
}
if err.Context["user_id"] != "123" {
t.Errorf("Expected user_id to be '123', got %v", err.Context["user_id"])
}
}
func TestDomainError_WithDetails(t *testing.T) {
err := NewDomainError(ErrValidationFailed, "validation failed")
err = err.WithDetails("name field is required")
expectedError := "validation failed: name field is required"
if err.Error() != expectedError {
t.Errorf("Expected error %q, got %q", expectedError, err.Error())
}
}
func TestDomainError_WithCause(t *testing.T) {
cause := errors.New("network timeout")
err := NewDomainErrorWithCause(ErrNetworkError, "operation failed", cause)
if err.Cause != cause {
t.Error("Expected cause to be preserved")
}
if !errors.Is(err, cause) {
t.Error("Expected error to wrap the cause")
}
}
func TestAppError_Creation(t *testing.T) {
appKey := AppKey{
Organization: "test-org",
Name: "test-app",
Version: "1.0.0",
}
err := NewAppError(ErrResourceNotFound, "ShowApp", appKey, "US", "not found")
expected := "operation ShowApp failed: resource app test-org/test-app version 1.0.0 in region US: not found"
if err.Error() != expected {
t.Errorf("Expected error %q, got %q", expected, err.Error())
}
if err.Resource.Type != "app" {
t.Errorf("Expected resource type 'app', got %q", err.Resource.Type)
}
}
func TestInstanceError_Creation(t *testing.T) {
instanceKey := AppInstanceKey{
Organization: "test-org",
Name: "test-instance",
CloudletKey: CloudletKey{
Organization: "cloudlet-org",
Name: "cloudlet-name",
},
}
err := NewInstanceError(ErrResourceNotFound, "ShowAppInstance", instanceKey, "US", "not found")
if err.Resource.Type != "app-instance" {
t.Errorf("Expected resource type 'app-instance', got %q", err.Resource.Type)
}
if err.Operation != "ShowAppInstance" {
t.Errorf("Expected operation 'ShowAppInstance', got %q", err.Operation)
}
}
func TestErrorChecking_Functions(t *testing.T) {
tests := []struct {
name string
err error
checkFn func(error) bool
expected bool
}{
{
name: "IsNotFoundError with not found error",
err: NewDomainError(ErrResourceNotFound, "not found"),
checkFn: IsNotFoundError,
expected: true,
},
{
name: "IsNotFoundError with validation error",
err: NewDomainError(ErrValidationFailed, "invalid"),
checkFn: IsNotFoundError,
expected: false,
},
{
name: "IsValidationError with validation error",
err: NewDomainError(ErrValidationFailed, "invalid"),
checkFn: IsValidationError,
expected: true,
},
{
name: "IsRetryableError with network error",
err: NewDomainError(ErrNetworkError, "connection failed"),
checkFn: IsRetryableError,
expected: true,
},
{
name: "IsRetryableError with validation error",
err: NewDomainError(ErrValidationFailed, "invalid"),
checkFn: IsRetryableError,
expected: false,
},
{
name: "IsAuthenticationError with auth error",
err: NewDomainError(ErrAuthenticationFailed, "unauthorized"),
checkFn: IsAuthenticationError,
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.checkFn(tt.err)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestErrorCode_String(t *testing.T) {
tests := []struct {
code ErrorCode
expected string
}{
{ErrResourceNotFound, "RESOURCE_NOT_FOUND"},
{ErrValidationFailed, "VALIDATION_FAILED"},
{ErrNetworkError, "NETWORK_ERROR"},
{ErrUnknownError, "UNKNOWN_ERROR"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
if tt.code.String() != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, tt.code.String())
}
})
}
}
func TestPredefinedErrors(t *testing.T) {
// Test that predefined errors have correct codes
if ErrAppNotFound.Code != ErrResourceNotFound {
t.Error("ErrAppNotFound should have ErrResourceNotFound code")
}
if ErrInvalidAppKey.Code != ErrValidationFailed {
t.Error("ErrInvalidAppKey should have ErrValidationFailed code")
}
if ErrDeploymentFailed.Code != ErrOperationNotAllowed {
t.Error("ErrDeploymentFailed should have ErrOperationNotAllowed code")
}
}

View file

@ -0,0 +1,6 @@
package domain
// Flavor defines resource allocation for instances
type Flavor struct {
Name string
}

View file

@ -0,0 +1,9 @@
package domain
// Organization represents the core business object for an organization.
// It contains identifying information such as name, address, and phone number.
type Organization struct {
Name string `json:"name"`
Address string `json:"address"`
Phone string `json:"phone"`
}

View file

@ -3,7 +3,6 @@
package config package config
import ( import (
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -11,56 +10,47 @@ import (
) )
func TestParseExampleConfig(t *testing.T) { func TestParseExampleConfig(t *testing.T) {
// The base path is relative to the location of this test file
parser := NewParser() parser := NewParser()
cfg, _, err := parser.ParseFile("../../../sdk/examples/comprehensive/EdgeConnectConfig.yaml")
// Parse the actual example file (now that we've created the manifest file)
examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig_v1.yaml")
config, parsedManifest, err := parser.ParseFile(examplePath)
// This should now succeed with full validation
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, config) require.NotNil(t, cfg)
require.NotEmpty(t, parsedManifest)
// Validate the parsed structure // Basic validation
assert.Equal(t, "edgeconnect-deployment", config.Kind) assert.Equal(t, "edgeconnect-deployment", cfg.Kind)
assert.Equal(t, "edge-app-demo", config.Metadata.Name) assert.Equal(t, "edge-app-demo", cfg.Metadata.Name)
assert.NotNil(t, cfg.Spec.K8sApp)
// Check k8s app configuration assert.NotEmpty(t, cfg.Spec.K8sApp.ManifestFile)
require.NotNil(t, config.Spec.K8sApp)
assert.Equal(t, "1.0.0", config.Metadata.AppVersion)
// Note: ManifestFile path should be resolved to absolute path
assert.Contains(t, config.Spec.K8sApp.ManifestFile, "k8s-deployment.yaml")
// Check infrastructure template // Check infrastructure template
require.Len(t, config.Spec.InfraTemplate, 1) require.Len(t, cfg.Spec.InfraTemplate, 1)
infra := config.Spec.InfraTemplate[0] infra := cfg.Spec.InfraTemplate[0]
assert.Equal(t, "EU", infra.Region) assert.Equal(t, "EU", infra.Region)
assert.Equal(t, "TelekomOP", infra.CloudletOrg) assert.Equal(t, "TelekomOP", infra.CloudletOrg)
assert.Equal(t, "Munich", infra.CloudletName) assert.Equal(t, "Munich", infra.CloudletName)
assert.Equal(t, "EU.small", infra.FlavorName) assert.Equal(t, "EU.small", infra.FlavorName)
// Check network configuration // Check network configuration
require.NotNil(t, config.Spec.Network) require.NotNil(t, cfg.Spec.Network)
require.Len(t, config.Spec.Network.OutboundConnections, 2) require.Len(t, cfg.Spec.Network.OutboundConnections, 2)
conn1 := config.Spec.Network.OutboundConnections[0] conn1 := cfg.Spec.Network.OutboundConnections[0]
assert.Equal(t, "tcp", conn1.Protocol) assert.Equal(t, "tcp", conn1.Protocol)
assert.Equal(t, 80, conn1.PortRangeMin) assert.Equal(t, 80, conn1.PortRangeMin)
assert.Equal(t, 80, conn1.PortRangeMax) assert.Equal(t, 80, conn1.PortRangeMax)
assert.Equal(t, "0.0.0.0/0", conn1.RemoteCIDR) assert.Equal(t, "0.0.0.0/0", conn1.RemoteCIDR)
conn2 := config.Spec.Network.OutboundConnections[1] conn2 := cfg.Spec.Network.OutboundConnections[1]
assert.Equal(t, "tcp", conn2.Protocol) assert.Equal(t, "tcp", conn2.Protocol)
assert.Equal(t, 443, conn2.PortRangeMin) assert.Equal(t, 443, conn2.PortRangeMin)
assert.Equal(t, 443, conn2.PortRangeMax) assert.Equal(t, 443, conn2.PortRangeMax)
assert.Equal(t, "0.0.0.0/0", conn2.RemoteCIDR) assert.Equal(t, "0.0.0.0/0", conn2.RemoteCIDR)
// Test utility methods // Test utility methods
assert.Equal(t, "edge-app-demo", config.Metadata.Name) assert.Equal(t, "edge-app-demo", cfg.Metadata.Name)
assert.Contains(t, config.Spec.GetManifestFile(), "k8s-deployment.yaml") assert.Contains(t, cfg.Spec.GetManifestFile(), "k8s-deployment.yaml")
assert.True(t, config.Spec.IsK8sApp()) assert.True(t, cfg.Spec.IsK8sApp())
assert.False(t, config.Spec.IsDockerApp()) assert.False(t, cfg.Spec.IsDockerApp())
} }
func TestValidateExampleStructure(t *testing.T) { func TestValidateExampleStructure(t *testing.T) {
@ -70,8 +60,8 @@ func TestValidateExampleStructure(t *testing.T) {
config := &EdgeConnectConfig{ config := &EdgeConnectConfig{
Kind: "edgeconnect-deployment", Kind: "edgeconnect-deployment",
Metadata: Metadata{ Metadata: Metadata{
Name: "edge-app-demo", Name: "edge-app-demo",
AppVersion: "1.0.0", AppVersion: "1.0.0",
Organization: "edp2", Organization: "edp2",
}, },
Spec: Spec{ Spec: Spec{

View file

@ -7,8 +7,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"gopkg.in/yaml.v3"
) )
// EdgeConnectConfig represents the top-level configuration structure // EdgeConnectConfig represents the top-level configuration structure
@ -100,75 +98,10 @@ func (c *EdgeConnectConfig) GetImagePath() string {
if c.Spec.IsDockerApp() && c.Spec.DockerApp.Image != "" { if c.Spec.IsDockerApp() && c.Spec.DockerApp.Image != "" {
return c.Spec.DockerApp.Image return c.Spec.DockerApp.Image
} }
// Default for kubernetes apps
// For kubernetes apps, extract image from manifest
if c.Spec.IsK8sApp() && c.Spec.K8sApp.ManifestFile != "" {
if image, err := extractImageFromK8sManifest(c.Spec.K8sApp.ManifestFile); err == nil && image != "" {
return image
}
}
// Fallback default for kubernetes apps
return "https://registry-1.docker.io/library/nginx:latest" return "https://registry-1.docker.io/library/nginx:latest"
} }
// extractImageFromK8sManifest extracts the container image from a Kubernetes manifest
func extractImageFromK8sManifest(manifestPath string) (string, error) {
data, err := os.ReadFile(manifestPath)
if err != nil {
return "", fmt.Errorf("failed to read manifest: %w", err)
}
// Parse multi-document YAML
decoder := yaml.NewDecoder(strings.NewReader(string(data)))
for {
var doc map[string]interface{}
if err := decoder.Decode(&doc); err != nil {
break // End of documents or error
}
// Check if this is a Deployment
kind, ok := doc["kind"].(string)
if !ok || kind != "Deployment" {
continue
}
// Navigate to spec.template.spec.containers[0].image
spec, ok := doc["spec"].(map[string]interface{})
if !ok {
continue
}
template, ok := spec["template"].(map[string]interface{})
if !ok {
continue
}
templateSpec, ok := template["spec"].(map[string]interface{})
if !ok {
continue
}
containers, ok := templateSpec["containers"].([]interface{})
if !ok || len(containers) == 0 {
continue
}
firstContainer, ok := containers[0].(map[string]interface{})
if !ok {
continue
}
image, ok := firstContainer["image"].(string)
if ok && image != "" {
return image, nil
}
}
return "", fmt.Errorf("no image found in Deployment manifest")
}
// Validate validates metadata fields // Validate validates metadata fields
func (m *Metadata) Validate() error { func (m *Metadata) Validate() error {
if m.Name == "" { if m.Name == "" {

View file

@ -1,7 +1,7 @@
// ABOUTME: Authentication providers for EdgeXR Master Controller API // ABOUTME: Authentication providers for EdgeXR Master Controller API
// ABOUTME: Supports Bearer token authentication with pluggable provider interface // ABOUTME: Supports Bearer token authentication with pluggable provider interface
package edgeconnect package edgeconnect_client
import ( import (
"bytes" "bytes"
@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -139,7 +140,10 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e
return "", err return "", err
} }
defer func() { defer func() {
_ = resp.Body.Close() if err := resp.Body.Close(); err != nil {
// Can't use c.logf here since this is in auth provider
fmt.Fprintf(os.Stderr, "Warning: failed to close auth response body: %v\n", err)
}
}() }()
// Read response body - same as existing implementation // Read response body - same as existing implementation

View file

@ -0,0 +1,353 @@
// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth
// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations
package edgeconnect_client
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/transport"
)
// Client represents the EdgeXR Master Controller SDK client
type Client struct {
BaseURL string
HTTPClient *http.Client
AuthProvider AuthProvider
RetryOpts transport.RetryOptions
Logger Logger
}
// Logger interface for optional logging
type Logger interface {
Printf(format string, v ...interface{})
}
// DefaultRetryOptions returns sensible default retry configuration
func DefaultRetryOptions() transport.RetryOptions {
return transport.RetryOptions{
MaxRetries: 3,
InitialDelay: 1 * time.Second,
MaxDelay: 30 * time.Second,
Multiplier: 2.0,
RetryableHTTPStatusCodes: []int{
http.StatusRequestTimeout,
http.StatusTooManyRequests,
http.StatusInternalServerError,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout,
},
}
}
// Option represents a configuration option for the client
type Option func(*Client)
// WithHTTPClient sets a custom HTTP client
func WithHTTPClient(client *http.Client) Option {
return func(c *Client) {
c.HTTPClient = client
}
}
// WithAuthProvider sets the authentication provider
func WithAuthProvider(auth AuthProvider) Option {
return func(c *Client) {
c.AuthProvider = auth
}
}
// WithRetryOptions sets retry configuration
func WithRetryOptions(opts transport.RetryOptions) Option {
return func(c *Client) {
c.RetryOpts = opts
}
}
// WithLogger sets a logger for debugging
func WithLogger(logger Logger) Option {
return func(c *Client) {
c.Logger = logger
}
}
// NewClient creates a new EdgeXR SDK client
func NewClient(baseURL string, options ...Option) *Client {
client := &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{Timeout: 30 * time.Second},
AuthProvider: NewNoAuthProvider(),
RetryOpts: DefaultRetryOptions(),
}
for _, opt := range options {
opt(client)
}
return client
}
// NewClientWithCredentials creates a new EdgeXR SDK client with username/password authentication
func NewClientWithCredentials(baseURL, username, password string, options ...Option) *Client {
// Pass the HTTP client from options to the provider if it exists
tempClient := &Client{
HTTPClient: &http.Client{Timeout: 30 * time.Second},
}
for _, opt := range options {
opt(tempClient)
}
client := &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: tempClient.HTTPClient,
AuthProvider: NewUsernamePasswordProvider(baseURL, username, password, tempClient.HTTPClient),
RetryOpts: DefaultRetryOptions(),
}
// Apply other options again, which might override the HTTPClient, but that's fine.
for _, opt := range options {
opt(client)
}
return client
}
// Logf logs a message if a logger is configured
func (c *Client) Logf(format string, v ...interface{}) {
if c.Logger != nil {
c.Logger.Printf(format, v...)
}
}
// Call performs a generic API call
func (c *Client) Call(ctx context.Context, method, path string, body, result interface{}) (*http.Response, error) {
t := c.getTransport()
url := c.BaseURL + path
resp, err := t.Call(ctx, method, url, body)
if err != nil {
return nil, fmt.Errorf("API call to %s %s failed: %w", method, path, err)
}
// If result is nil, the caller doesn't expect a body to be parsed.
// They are responsible for closing the response body.
if result == nil {
return resp, nil
}
// If result is not nil, we handle the body and closing it.
defer func() {
if err := resp.Body.Close(); err != nil {
c.Logf("Failed to close response body: %v", err)
}
}()
if resp.StatusCode >= 400 {
return resp, c.handleErrorResponse(resp, fmt.Sprintf("%s %s", method, path))
}
// Handle different response types
switch v := result.(type) {
case *string:
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return resp, fmt.Errorf("failed to read response body: %w", err)
}
*v = string(bodyBytes)
case io.Writer:
_, err := io.Copy(v, resp.Body)
if err != nil {
return resp, fmt.Errorf("failed to write response body: %w", err)
}
default:
// Default to JSON decoding
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return resp, fmt.Errorf("failed to decode JSON response: %w", err)
}
}
return resp, nil
}
// getTransport creates an HTTP transport with current client settings
func (c *Client) getTransport() *transport.Transport {
return transport.NewTransport(
c.RetryOpts,
c.AuthProvider,
c.Logger,
)
}
// handleErrorResponse creates an appropriate error from HTTP error response
func (c *Client) handleErrorResponse(resp *http.Response, operation string) error {
messages := []string{
fmt.Sprintf("%s failed with status %d", operation, resp.StatusCode),
}
bodyBytes, _ := io.ReadAll(resp.Body) // Read body, ignore error as it might be empty
if len(bodyBytes) > 0 {
messages = append(messages, string(bodyBytes))
}
return &APIError{
StatusCode: resp.StatusCode,
Messages: messages,
Body: bodyBytes,
}
}
// parseStreamingResponse parses the EdgeXR streaming JSON response format
func (c *Client) ParseStreamingResponse(resp *http.Response, result interface{}) error {
var responses []Response[App]
parseErr := transport.ParseJSONLines(resp.Body, func(line []byte) error {
var response Response[App]
if err := json.Unmarshal(line, &response); err != nil {
return err
}
responses = append(responses, response)
return nil
})
if parseErr != nil {
return parseErr
}
// Extract data from responses
var apps []App
var messages []string
for _, response := range responses {
if response.HasData() {
apps = append(apps, response.Data)
}
if response.IsMessage() {
messages = append(messages, response.Data.GetMessage())
}
}
// If we have error messages, return them
if len(messages) > 0 {
return &APIError{
StatusCode: resp.StatusCode,
Messages: messages,
}
}
// Set result based on type
switch v := result.(type) {
case *[]App:
*v = apps
default:
return fmt.Errorf("unsupported result type: %T", result)
}
return nil
}
// Helper functions for parsing streaming responses
// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances
func (c *Client) ParseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error {
var responses []Response[AppInstance]
parseErr := transport.ParseJSONLines(resp.Body, func(line []byte) error {
var response Response[AppInstance]
if err := json.Unmarshal(line, &response); err != nil {
return err
}
responses = append(responses, response)
return nil
})
if parseErr != nil {
return parseErr
}
// Extract data from responses
var appInstances []AppInstance
var messages []string
for _, response := range responses {
if response.HasData() {
appInstances = append(appInstances, response.Data)
}
if response.IsMessage() {
messages = append(messages, response.Data.GetMessage())
}
}
// If we have error messages, return them
if len(messages) > 0 {
return &APIError{
StatusCode: resp.StatusCode,
Messages: messages,
}
}
// Set result based on type
switch v := result.(type) {
case *[]AppInstance:
*v = appInstances
default:
return fmt.Errorf("unsupported result type: %T", result)
}
return nil
}
// parseStreamingCloudletResponse parses the EdgeXR streaming JSON response format for cloudlets
func (c *Client) ParseStreamingCloudletResponse(resp *http.Response, result interface{}) error {
var responses []Response[Cloudlet]
parseErr := transport.ParseJSONLines(resp.Body, func(line []byte) error {
var response Response[Cloudlet]
if err := json.Unmarshal(line, &response); err != nil {
return err
}
responses = append(responses, response)
return nil
})
if parseErr != nil {
return parseErr
}
// Extract data from responses
var cloudlets []Cloudlet
var messages []string
for _, response := range responses {
if response.HasData() {
cloudlets = append(cloudlets, response.Data)
}
if response.IsMessage() {
messages = append(messages, response.Data.GetMessage())
}
}
// If we have error messages, return them
if len(messages) > 0 {
return &APIError{
StatusCode: resp.StatusCode,
Messages: messages,
}
}
// Set result based on type
switch v := result.(type) {
case *[]Cloudlet:
*v = cloudlets
default:
return fmt.Errorf("unsupported result type: %T", result)
}
return nil
}

View file

@ -1,12 +1,14 @@
// ABOUTME: Core type definitions for EdgeXR Master Controller SDK // ABOUTME: Core type definitions for EdgeXR Master Controller SDK
// ABOUTME: These types are based on the swagger API specification and existing client patterns // ABOUTME: These types are based on the swagger API specification and existing client patterns
package edgeconnect package edgeconnect_client
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"time" "time"
) )
// App field constants for partial updates (based on EdgeXR API specification) // App field constants for partial updates (based on EdgeXR API specification)
@ -60,74 +62,74 @@ const (
// AppInstance field constants for partial updates (based on EdgeXR API specification) // AppInstance field constants for partial updates (based on EdgeXR API specification)
const ( const (
AppInstFieldKey = "2" AppInstFieldKey = "2"
AppInstFieldKeyAppKey = "2.1" AppInstFieldKeyAppKey = "2.1"
AppInstFieldKeyAppKeyOrganization = "2.1.1" AppInstFieldKeyAppKeyOrganization = "2.1.1"
AppInstFieldKeyAppKeyName = "2.1.2" AppInstFieldKeyAppKeyName = "2.1.2"
AppInstFieldKeyAppKeyVersion = "2.1.3" AppInstFieldKeyAppKeyVersion = "2.1.3"
AppInstFieldKeyClusterInstKey = "2.4" AppInstFieldKeyClusterInstKey = "2.4"
AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1" AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1"
AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1" AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1"
AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2" AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2"
AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1" AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1"
AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2" AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2"
AppInstFieldKeyClusterInstKeyCloudletKeyFederatedOrganization = "2.4.2.3" AppInstFieldKeyClusterInstKeyCloudletKeyFederatedOrganization = "2.4.2.3"
AppInstFieldKeyClusterInstKeyOrganization = "2.4.3" AppInstFieldKeyClusterInstKeyOrganization = "2.4.3"
AppInstFieldCloudletLoc = "3" AppInstFieldCloudletLoc = "3"
AppInstFieldCloudletLocLatitude = "3.1" AppInstFieldCloudletLocLatitude = "3.1"
AppInstFieldCloudletLocLongitude = "3.2" AppInstFieldCloudletLocLongitude = "3.2"
AppInstFieldCloudletLocHorizontalAccuracy = "3.3" AppInstFieldCloudletLocHorizontalAccuracy = "3.3"
AppInstFieldCloudletLocVerticalAccuracy = "3.4" AppInstFieldCloudletLocVerticalAccuracy = "3.4"
AppInstFieldCloudletLocAltitude = "3.5" AppInstFieldCloudletLocAltitude = "3.5"
AppInstFieldCloudletLocCourse = "3.6" AppInstFieldCloudletLocCourse = "3.6"
AppInstFieldCloudletLocSpeed = "3.7" AppInstFieldCloudletLocSpeed = "3.7"
AppInstFieldCloudletLocTimestamp = "3.8" AppInstFieldCloudletLocTimestamp = "3.8"
AppInstFieldCloudletLocTimestampSeconds = "3.8.1" AppInstFieldCloudletLocTimestampSeconds = "3.8.1"
AppInstFieldCloudletLocTimestampNanos = "3.8.2" AppInstFieldCloudletLocTimestampNanos = "3.8.2"
AppInstFieldUri = "4" AppInstFieldUri = "4"
AppInstFieldLiveness = "6" AppInstFieldLiveness = "6"
AppInstFieldMappedPorts = "9" AppInstFieldMappedPorts = "9"
AppInstFieldMappedPortsProto = "9.1" AppInstFieldMappedPortsProto = "9.1"
AppInstFieldMappedPortsInternalPort = "9.2" AppInstFieldMappedPortsInternalPort = "9.2"
AppInstFieldMappedPortsPublicPort = "9.3" AppInstFieldMappedPortsPublicPort = "9.3"
AppInstFieldMappedPortsFqdnPrefix = "9.5" AppInstFieldMappedPortsFqdnPrefix = "9.5"
AppInstFieldMappedPortsEndPort = "9.6" AppInstFieldMappedPortsEndPort = "9.6"
AppInstFieldMappedPortsTls = "9.7" AppInstFieldMappedPortsTls = "9.7"
AppInstFieldMappedPortsNginx = "9.8" AppInstFieldMappedPortsNginx = "9.8"
AppInstFieldMappedPortsMaxPktSize = "9.9" AppInstFieldMappedPortsMaxPktSize = "9.9"
AppInstFieldFlavor = "12" AppInstFieldFlavor = "12"
AppInstFieldFlavorName = "12.1" AppInstFieldFlavorName = "12.1"
AppInstFieldState = "14" AppInstFieldState = "14"
AppInstFieldErrors = "15" AppInstFieldErrors = "15"
AppInstFieldCrmOverride = "16" AppInstFieldCrmOverride = "16"
AppInstFieldRuntimeInfo = "17" AppInstFieldRuntimeInfo = "17"
AppInstFieldRuntimeInfoContainerIds = "17.1" AppInstFieldRuntimeInfoContainerIds = "17.1"
AppInstFieldCreatedAt = "21" AppInstFieldCreatedAt = "21"
AppInstFieldCreatedAtSeconds = "21.1" AppInstFieldCreatedAtSeconds = "21.1"
AppInstFieldCreatedAtNanos = "21.2" AppInstFieldCreatedAtNanos = "21.2"
AppInstFieldAutoClusterIpAccess = "22" AppInstFieldAutoClusterIpAccess = "22"
AppInstFieldRevision = "24" AppInstFieldRevision = "24"
AppInstFieldForceUpdate = "25" AppInstFieldForceUpdate = "25"
AppInstFieldUpdateMultiple = "26" AppInstFieldUpdateMultiple = "26"
AppInstFieldConfigs = "27" AppInstFieldConfigs = "27"
AppInstFieldConfigsKind = "27.1" AppInstFieldConfigsKind = "27.1"
AppInstFieldConfigsConfig = "27.2" AppInstFieldConfigsConfig = "27.2"
AppInstFieldHealthCheck = "29" AppInstFieldHealthCheck = "29"
AppInstFieldPowerState = "31" AppInstFieldPowerState = "31"
AppInstFieldExternalVolumeSize = "32" AppInstFieldExternalVolumeSize = "32"
AppInstFieldAvailabilityZone = "33" AppInstFieldAvailabilityZone = "33"
AppInstFieldVmFlavor = "34" AppInstFieldVmFlavor = "34"
AppInstFieldOptRes = "35" AppInstFieldOptRes = "35"
AppInstFieldUpdatedAt = "36" AppInstFieldUpdatedAt = "36"
AppInstFieldUpdatedAtSeconds = "36.1" AppInstFieldUpdatedAtSeconds = "36.1"
AppInstFieldUpdatedAtNanos = "36.2" AppInstFieldUpdatedAtNanos = "36.2"
AppInstFieldRealClusterName = "37" AppInstFieldRealClusterName = "37"
AppInstFieldInternalPortToLbIp = "38" AppInstFieldInternalPortToLbIp = "38"
AppInstFieldInternalPortToLbIpKey = "38.1" AppInstFieldInternalPortToLbIpKey = "38.1"
AppInstFieldInternalPortToLbIpValue = "38.2" AppInstFieldInternalPortToLbIpValue = "38.2"
AppInstFieldDedicatedIp = "39" AppInstFieldDedicatedIp = "39"
AppInstFieldUniqueId = "40" AppInstFieldUniqueId = "40"
AppInstFieldDnsLabel = "41" AppInstFieldDnsLabel = "41"
) )
// Message interface for types that can provide error messages // Message interface for types that can provide error messages
@ -271,26 +273,6 @@ func (res *Response[T]) IsMessage() bool {
return res.Data.GetMessage() != "" return res.Data.GetMessage() != ""
} }
// ResultResponse represents an API result with error code
type ResultResponse struct {
Result struct {
Message string `json:"message"`
Code int `json:"code"`
} `json:"result"`
}
func (r *ResultResponse) IsError() bool {
return r.Result.Code >= 400
}
func (r *ResultResponse) GetMessage() string {
return r.Result.Message
}
func (r *ResultResponse) GetCode() int {
return r.Result.Code
}
// Responses wraps multiple API responses with metadata // Responses wraps multiple API responses with metadata
type Responses[T Message] struct { type Responses[T Message] struct {
Responses []Response[T] `json:"responses,omitempty"` Responses []Response[T] `json:"responses,omitempty"`
@ -378,4 +360,4 @@ type CloudletResourceUsage struct {
CloudletKey CloudletKey `json:"cloudlet_key"` CloudletKey CloudletKey `json:"cloudlet_key"`
Region string `json:"region"` Region string `json:"region"`
Usage map[string]interface{} `json:"usage"` Usage map[string]interface{} `json:"usage"`
} }

View file

@ -0,0 +1,28 @@
package transport
import (
"encoding/json"
"fmt"
"io"
)
// ParseJSONLines parses streaming JSON response line by line
func ParseJSONLines(body io.Reader, callback func([]byte) error) error {
decoder := json.NewDecoder(body)
for {
var raw json.RawMessage
if err := decoder.Decode(&raw); err != nil {
if err == io.EOF {
break
}
return fmt.Errorf("failed to decode JSON line: %w", err)
}
if err := callback(raw); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,137 @@
package transport
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"time"
)
// AuthProvider defines the interface for attaching authentication to requests.
type AuthProvider interface {
Attach(ctx context.Context, req *http.Request) error
}
// Logger defines the interface for logging.
type Logger interface {
Printf(format string, v ...interface{})
}
// RetryOptions configures the retry behavior for API calls.
type RetryOptions struct {
MaxRetries int
InitialDelay time.Duration
MaxDelay time.Duration
Multiplier float64
RetryableHTTPStatusCodes []int
}
// Transport handles the lifecycle of an HTTP request, including authentication and retries.
type Transport struct {
retryOptions RetryOptions
authProvider AuthProvider
logger Logger
httpClient *http.Client
}
// NewTransport creates a new Transport.
func NewTransport(retryOptions RetryOptions, authProvider AuthProvider, logger Logger) *Transport {
return &Transport{
retryOptions: retryOptions,
authProvider: authProvider,
logger: logger,
httpClient: &http.Client{}, // Use a default client
}
}
// Call executes an HTTP request with the configured transport options.
func (t *Transport) Call(ctx context.Context, method, url string, body interface{}) (*http.Response, error) {
var reqBody io.Reader
if body != nil {
// Marshal body to JSON
jsonData, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewBuffer(jsonData)
}
var resp *http.Response
var err error
for i := 0; i <= t.retryOptions.MaxRetries; i++ {
// Create a new request for each attempt
req, reqErr := http.NewRequestWithContext(ctx, method, url, reqBody)
if reqErr != nil {
return nil, fmt.Errorf("failed to create request: %w", reqErr)
}
req.Header.Set("Content-Type", "application/json")
// Attach authentication
if authErr := t.authProvider.Attach(ctx, req); authErr != nil {
return nil, fmt.Errorf("failed to attach authentication: %w", authErr)
}
// Perform the request
resp, err = t.httpClient.Do(req)
if err != nil {
t.logf("Request failed (attempt %d): %v", i+1, err)
// Decide if we should retry based on the error (e.g., network errors)
if i < t.retryOptions.MaxRetries {
time.Sleep(t.calculateBackoff(i))
continue
}
return nil, fmt.Errorf("request failed after %d attempts: %w", t.retryOptions.MaxRetries+1, err)
}
// Check if we should retry based on status code
if t.isRetryable(resp.StatusCode) && i < t.retryOptions.MaxRetries {
t.logf("Request returned retryable status %d (attempt %d)", resp.StatusCode, i+1)
// We need to close the body before retrying
if resp.Body != nil {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
time.Sleep(t.calculateBackoff(i))
continue
}
// If not retryable, break the loop
break
}
return resp, err
}
// isRetryable checks if an HTTP status code is in the list of retryable codes.
func (t *Transport) isRetryable(statusCode int) bool {
for _, code := range t.retryOptions.RetryableHTTPStatusCodes {
if statusCode == code {
return true
}
}
return false
}
// calculateBackoff computes the delay for the next retry attempt.
func (t *Transport) calculateBackoff(attempt int) time.Duration {
if attempt == 0 {
return t.retryOptions.InitialDelay
}
delay := float64(t.retryOptions.InitialDelay) * math.Pow(t.retryOptions.Multiplier, float64(attempt))
if delay > float64(t.retryOptions.MaxDelay) {
return t.retryOptions.MaxDelay
}
return time.Duration(delay)
}
// logf logs a message if a logger is configured.
func (t *Transport) logf(format string, v ...interface{}) {
if t.logger != nil {
t.logger.Printf(format, v...)
}
}

View file

@ -0,0 +1,14 @@
package driven
import (
"context"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
)
type AppRepository interface {
CreateApp(ctx context.Context, region string, app *domain.App) error
ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error)
ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error)
DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error
UpdateApp(ctx context.Context, region string, app *domain.App) error
}

View file

@ -0,0 +1,13 @@
package driven
import (
"context"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
)
type CloudletRepository interface {
CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error
ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error)
ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error)
DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error
}

View file

@ -0,0 +1,15 @@
package driven
import (
"context"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
)
type AppInstanceRepository interface {
CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error
ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error)
ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error)
DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error
UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error
RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error
}

View file

@ -0,0 +1,20 @@
package driven
import (
"context"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
)
// OrganizationRepository defines the port for interacting with organization data storage.
// This interface provides a technology-agnostic way for the core application to manage organizations.
type OrganizationRepository interface {
// CreateOrganization persists a new organization.
CreateOrganization(ctx context.Context, org *domain.Organization) error
// ShowOrganization retrieves a single organization by its name.
ShowOrganization(ctx context.Context, name string) (*domain.Organization, error)
// UpdateOrganization updates an existing organization.
UpdateOrganization(ctx context.Context, org *domain.Organization) error
// DeleteOrganization removes an organization by its name.
DeleteOrganization(ctx context.Context, name string) error
}

View file

@ -0,0 +1,14 @@
package driving
import (
"context"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
)
type AppService interface {
CreateApp(ctx context.Context, region string, app *domain.App) error
ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error)
ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error)
DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error
UpdateApp(ctx context.Context, region string, app *domain.App) error
}

View file

@ -0,0 +1,13 @@
package driving
import (
"context"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
)
type CloudletService interface {
CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error
ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error)
ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error)
DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error
}

View file

@ -0,0 +1,15 @@
package driving
import (
"context"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
)
type AppInstanceService interface {
CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error
ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error)
ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error)
DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error
UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error
RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error
}

View file

@ -0,0 +1,16 @@
package driving
import (
"context"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
)
// OrganizationService defines the driving port for managing organizations.
// This is the primary interface for interacting with the application's organization logic.
type OrganizationService interface {
Create(ctx context.Context, org *domain.Organization) error
Get(ctx context.Context, name string) (*domain.Organization, error)
Update(ctx context.Context, org *domain.Organization) error
Delete(ctx context.Context, name string) error
}

View file

@ -1,7 +0,0 @@
package main
import "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/cmd"
func main() {
cmd.Execute()
}

Binary file not shown.

View file

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

View file

@ -1,280 +0,0 @@
// ABOUTME: Application instance lifecycle management APIs for EdgeXR Master Controller
// ABOUTME: Provides typed methods for creating, querying, and deleting application instances
package edgeconnect
import (
"context"
"encoding/json"
"fmt"
"net/http"
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
)
// CreateAppInstance creates a new application instance in the specified region
// Maps to POST /auth/ctrl/CreateAppInst
func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInput) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/CreateAppInst"
resp, err := transport.Call(ctx, "POST", url, input)
if err != nil {
return fmt.Errorf("CreateAppInstance failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateAppInstance")
}
// Parse streaming JSON response
var appInstances []AppInstance
if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil {
return fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
}
c.logf("CreateAppInstance: %s/%s created successfully",
input.AppInst.Key.Organization, input.AppInst.Key.Name)
return nil
}
// ShowAppInstance retrieves a single application instance by key and region
// Maps to POST /auth/ctrl/ShowAppInst
func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) (AppInstance, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
filter := AppInstanceFilter{
AppInstance: AppInstance{AppKey: appKey, Key: appInstKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return AppInstance{}, fmt.Errorf("app instance %s/%s: %w",
appInstKey.Organization, appInstKey.Name, ErrResourceNotFound)
}
if resp.StatusCode >= 400 {
return AppInstance{}, c.handleErrorResponse(resp, "ShowAppInstance")
}
// Parse streaming JSON response
var appInstances []AppInstance
if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil {
return AppInstance{}, fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
}
if len(appInstances) == 0 {
return AppInstance{}, fmt.Errorf("app instance %s/%s in region %s: %w",
appInstKey.Organization, appInstKey.Name, region, ErrResourceNotFound)
}
return appInstances[0], nil
}
// ShowAppInstances retrieves all application instances matching the filter criteria
// Maps to POST /auth/ctrl/ShowAppInst
func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) ([]AppInstance, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
filter := AppInstanceFilter{
AppInstance: AppInstance{Key: appInstKey, AppKey: appKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowAppInstances")
}
var appInstances []AppInstance
if resp.StatusCode == http.StatusNotFound {
return appInstances, nil // Return empty slice for not found
}
if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil {
return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err)
}
c.logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances))
return appInstances, nil
}
// UpdateAppInstance updates an application instance and then refreshes it
// Maps to POST /auth/ctrl/UpdateAppInst
func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstanceInput) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/UpdateAppInst"
resp, err := transport.Call(ctx, "POST", url, input)
if err != nil {
return fmt.Errorf("UpdateAppInstance failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "UpdateAppInstance")
}
c.logf("UpdateAppInstance: %s/%s updated successfully",
input.AppInst.Key.Organization, input.AppInst.Key.Name)
return nil
}
// RefreshAppInstance refreshes an application instance's state
// Maps to POST /auth/ctrl/RefreshAppInst
func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/RefreshAppInst"
filter := AppInstanceFilter{
AppInstance: AppInstance{Key: appInstKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return fmt.Errorf("RefreshAppInstance failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "RefreshAppInstance")
}
c.logf("RefreshAppInstance: %s/%s refreshed successfully",
appInstKey.Organization, appInstKey.Name)
return nil
}
// DeleteAppInstance removes an application instance from the specified region
// Maps to POST /auth/ctrl/DeleteAppInst
func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst"
filter := AppInstanceFilter{
AppInstance: AppInstance{Key: appInstKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return fmt.Errorf("DeleteAppInstance failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return c.handleErrorResponse(resp, "DeleteAppInstance")
}
c.logf("DeleteAppInstance: %s/%s deleted successfully",
appInstKey.Organization, appInstKey.Name)
return nil
}
// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances
func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error {
var appInstances []AppInstance
var messages []string
var hasError bool
var errorCode int
var errorMessage string
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)
var resultResp ResultResponse
if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" {
if resultResp.IsError() {
hasError = true
errorCode = resultResp.GetCode()
errorMessage = resultResp.GetMessage()
}
return nil
}
// Try parsing as Response[AppInstance]
var response Response[AppInstance]
if err := json.Unmarshal(line, &response); err != nil {
return err
}
if response.HasData() {
appInstances = append(appInstances, response.Data)
}
if response.IsMessage() {
msg := response.Data.GetMessage()
messages = append(messages, msg)
// Check for error indicators in messages
if msg == "CreateError" || msg == "UpdateError" || msg == "DeleteError" {
hasError = true
}
}
return nil
})
if parseErr != nil {
return parseErr
}
// If we detected an error, return it
if hasError {
apiErr := &APIError{
StatusCode: resp.StatusCode,
Messages: messages,
}
if errorCode > 0 {
apiErr.StatusCode = errorCode
apiErr.Code = fmt.Sprintf("%d", errorCode)
}
if errorMessage != "" {
apiErr.Messages = append([]string{errorMessage}, apiErr.Messages...)
}
return apiErr
}
// Set result based on type
switch v := result.(type) {
case *[]AppInstance:
*v = appInstances
default:
return fmt.Errorf("unsupported result type: %T", result)
}
return nil
}

View file

@ -1,527 +0,0 @@
// ABOUTME: Unit tests for AppInstance management APIs using httptest mock server
// ABOUTME: Tests create, show, list, refresh, and delete operations with error conditions
package edgeconnect
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateAppInstance(t *testing.T) {
tests := []struct {
name string
input *NewAppInstanceInput
mockStatusCode int
mockResponse string
expectError bool
errorContains string
}{
{
name: "successful creation",
input: &NewAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
AppKey: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
Flavor: Flavor{Name: "m4.small"},
},
},
mockStatusCode: 200,
mockResponse: `{"message": "success"}`,
expectError: false,
},
{
name: "validation error",
input: &NewAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "",
Name: "testinst",
},
},
},
mockStatusCode: 400,
mockResponse: `{"message": "organization is required"}`,
expectError: true,
},
{
name: "HTTP 200 with CreateError message",
input: &NewAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
Flavor: Flavor{Name: "m4.small"},
},
},
mockStatusCode: 200,
mockResponse: `{"data":{"message":"Creating"}}
{"data":{"message":"a service has been configured"}}
{"data":{"message":"CreateError"}}
{"data":{"message":"Deleting AppInst due to failure"}}
{"data":{"message":"Deleted AppInst successfully"}}`,
expectError: true,
errorContains: "CreateError",
},
{
name: "HTTP 200 with result error code",
input: &NewAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
Flavor: Flavor{Name: "m4.small"},
},
},
mockStatusCode: 200,
mockResponse: `{"data":{"message":"Creating"}}
{"data":{"message":"a service has been configured"}}
{"data":{"message":"CreateError"}}
{"data":{"message":"Deleting AppInst due to failure"}}
{"data":{"message":"Deleted AppInst successfully"}}
{"result":{"message":"Encountered failures: Create App Inst failed: deployments.apps is forbidden: User \"system:serviceaccount:edgexr:crm-telekomop-munich\" cannot create resource \"deployments\" in API group \"apps\" in the namespace \"gitea\"","code":400}}`,
expectError: true,
errorContains: "deployments.apps is forbidden",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/CreateAppInst", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
WithAuthProvider(NewStaticTokenProvider("test-token")),
)
// Execute test
ctx := context.Background()
err := client.CreateAppInstance(ctx, tt.input)
// Verify results
if tt.expectError {
assert.Error(t, err)
if tt.errorContains != "" {
assert.Contains(t, err.Error(), tt.errorContains)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestShowAppInstance(t *testing.T) {
tests := []struct {
name string
appKey AppKey
appInstKey AppInstanceKey
region string
mockStatusCode int
mockResponse string
expectError bool
expectNotFound bool
}{
{
name: "successful show",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
appKey: AppKey{Name: "test-app-id"},
region: "us-west",
mockStatusCode: 200,
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}}
`,
expectError: false,
expectNotFound: false,
},
{
name: "instance not found",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "nonexistent",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
appKey: AppKey{Name: "test-app-id"},
region: "us-west",
mockStatusCode: 404,
mockResponse: "",
expectError: true,
expectNotFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" {
_, _ = w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
)
// Execute test
ctx := context.Background()
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region)
// Verify results
if tt.expectError {
assert.Error(t, err)
if tt.expectNotFound {
assert.Contains(t, err.Error(), "resource not found")
}
} else {
require.NoError(t, err)
assert.Equal(t, tt.appInstKey.Organization, appInst.Key.Organization)
assert.Equal(t, tt.appInstKey.Name, appInst.Key.Name)
assert.Equal(t, "Ready", appInst.State)
}
})
}
}
func TestShowAppInstances(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path)
// Verify request body
var filter AppInstanceFilter
err := json.NewDecoder(r.Body).Decode(&filter)
require.NoError(t, err)
assert.Equal(t, "testorg", filter.AppInstance.Key.Organization)
assert.Equal(t, "us-west", filter.Region)
// Return multiple app instances
response := `{"data": {"key": {"organization": "testorg", "name": "inst1"}, "state": "Ready"}}
{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}}
`
w.WriteHeader(200)
_, _ = w.Write([]byte(response))
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west")
require.NoError(t, err)
assert.Len(t, appInstances, 2)
assert.Equal(t, "inst1", appInstances[0].Key.Name)
assert.Equal(t, "Ready", appInstances[0].State)
assert.Equal(t, "inst2", appInstances[1].Key.Name)
assert.Equal(t, "Creating", appInstances[1].State)
}
func TestUpdateAppInstance(t *testing.T) {
tests := []struct {
name string
input *UpdateAppInstanceInput
mockStatusCode int
mockResponse string
expectError bool
}{
{
name: "successful update",
input: &UpdateAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
AppKey: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
Flavor: Flavor{Name: "m4.medium"},
PowerState: "PowerOn",
},
},
mockStatusCode: 200,
mockResponse: `{"message": "success"}`,
expectError: false,
},
{
name: "validation error",
input: &UpdateAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
},
},
mockStatusCode: 400,
mockResponse: `{"message": "organization is required"}`,
expectError: true,
},
{
name: "instance not found",
input: &UpdateAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "testorg",
Name: "nonexistent",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
},
},
mockStatusCode: 404,
mockResponse: `{"message": "app instance not found"}`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/UpdateAppInst", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
// Verify request body
var input UpdateAppInstanceInput
err := json.NewDecoder(r.Body).Decode(&input)
require.NoError(t, err)
assert.Equal(t, tt.input.Region, input.Region)
assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization)
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
WithAuthProvider(NewStaticTokenProvider("test-token")),
)
// Execute test
ctx := context.Background()
err := client.UpdateAppInstance(ctx, tt.input)
// Verify results
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestRefreshAppInstance(t *testing.T) {
tests := []struct {
name string
appInstKey AppInstanceKey
region string
mockStatusCode int
expectError bool
}{
{
name: "successful refresh",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
region: "us-west",
mockStatusCode: 200,
expectError: false,
},
{
name: "server error",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
region: "us-west",
mockStatusCode: 500,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/RefreshAppInst", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
err := client.RefreshAppInstance(ctx, tt.appInstKey, tt.region)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestDeleteAppInstance(t *testing.T) {
tests := []struct {
name string
appInstKey AppInstanceKey
region string
mockStatusCode int
expectError bool
}{
{
name: "successful deletion",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
region: "us-west",
mockStatusCode: 200,
expectError: false,
},
{
name: "already deleted (404 ok)",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
region: "us-west",
mockStatusCode: 404,
expectError: false,
},
{
name: "server error",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
region: "us-west",
mockStatusCode: 500,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/DeleteAppInst", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
err := client.DeleteAppInstance(ctx, tt.appInstKey, tt.region)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

View file

@ -1,268 +0,0 @@
// ABOUTME: Application lifecycle management APIs for EdgeXR Master Controller
// ABOUTME: Provides typed methods for creating, querying, and deleting applications
package edgeconnect
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
)
var (
// ErrResourceNotFound indicates the requested resource was 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
// Maps to POST /auth/ctrl/CreateApp
func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/CreateApp"
resp, err := transport.Call(ctx, "POST", url, input)
if err != nil {
return fmt.Errorf("CreateApp failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateApp")
}
c.logf("CreateApp: %s/%s version %s created successfully",
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
return nil
}
// ShowApp retrieves a single application by key and region
// Maps to POST /auth/ctrl/ShowApp
func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp"
filter := AppFilter{
App: App{Key: appKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return App{}, fmt.Errorf("ShowApp failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound)
}
if resp.StatusCode >= 400 {
return App{}, c.handleErrorResponse(resp, "ShowApp")
}
// Parse streaming JSON response
var apps []App
if err := c.parseStreamingResponse(resp, &apps); err != nil {
return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err)
}
if len(apps) == 0 {
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound)
}
return apps[0], nil
}
// ShowApps retrieves all applications matching the filter criteria
// Maps to POST /auth/ctrl/ShowApp
func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]App, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp"
filter := AppFilter{
App: App{Key: appKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return nil, fmt.Errorf("ShowApps failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowApps")
}
var apps []App
if resp.StatusCode == http.StatusNotFound {
return apps, nil // Return empty slice for not found
}
if err := c.parseStreamingResponse(resp, &apps); err != nil {
return nil, fmt.Errorf("ShowApps failed to parse response: %w", err)
}
c.logf("ShowApps: found %d apps matching criteria", len(apps))
return apps, nil
}
// UpdateApp updates the definition of an application
// Maps to POST /auth/ctrl/UpdateApp
func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/UpdateApp"
resp, err := transport.Call(ctx, "POST", url, input)
if err != nil {
return fmt.Errorf("UpdateApp failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "UpdateApp")
}
c.logf("UpdateApp: %s/%s version %s updated successfully",
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
return nil
}
// DeleteApp removes an application from the specified region
// Maps to POST /auth/ctrl/DeleteApp
func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp"
filter := AppFilter{
App: App{Key: appKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return fmt.Errorf("DeleteApp failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return c.handleErrorResponse(resp, "DeleteApp")
}
c.logf("DeleteApp: %s/%s version %s deleted successfully",
appKey.Organization, appKey.Name, appKey.Version)
return nil
}
// parseStreamingResponse parses the EdgeXR streaming JSON response format
func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error {
var responses []Response[App]
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]
if err := json.Unmarshal(line, &response); err != nil {
return err
}
responses = append(responses, response)
return nil
})
if parseErr != nil {
return parseErr
}
// Extract data from responses
var apps []App
var messages []string
for _, response := range responses {
if response.HasData() {
apps = append(apps, response.Data)
}
if response.IsMessage() {
messages = append(messages, response.Data.GetMessage())
}
}
// If we have error messages, return them
if len(messages) > 0 {
return &APIError{
StatusCode: resp.StatusCode,
Messages: messages,
}
}
// Set result based on type
switch v := result.(type) {
case *[]App:
*v = apps
default:
return fmt.Errorf("unsupported result type: %T", result)
}
return nil
}
// getTransport creates an HTTP transport with current client settings
func (c *Client) getTransport() *sdkhttp.Transport {
return sdkhttp.NewTransport(
sdkhttp.RetryOptions{
MaxRetries: c.RetryOpts.MaxRetries,
InitialDelay: c.RetryOpts.InitialDelay,
MaxDelay: c.RetryOpts.MaxDelay,
Multiplier: c.RetryOpts.Multiplier,
RetryableHTTPStatusCodes: c.RetryOpts.RetryableHTTPStatusCodes,
},
c.AuthProvider,
c.Logger,
)
}
// handleErrorResponse creates an appropriate error from HTTP error response
func (c *Client) handleErrorResponse(resp *http.Response, operation string) error {
messages := []string{
fmt.Sprintf("%s failed with status %d", operation, resp.StatusCode),
}
bodyBytes := []byte{}
if resp.Body != nil {
defer func() {
_ = resp.Body.Close()
}()
bodyBytes, _ = io.ReadAll(resp.Body)
messages = append(messages, string(bodyBytes))
}
return &APIError{
StatusCode: resp.StatusCode,
Messages: messages,
Body: bodyBytes,
}
}

View file

@ -1,409 +0,0 @@
// ABOUTME: Unit tests for App management APIs using httptest mock server
// ABOUTME: Tests create, show, list, and delete operations with error conditions
package edgeconnect
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateApp(t *testing.T) {
tests := []struct {
name string
input *NewAppInput
mockStatusCode int
mockResponse string
expectError bool
}{
{
name: "successful creation",
input: &NewAppInput{
Region: "us-west",
App: App{
Key: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
Deployment: "kubernetes",
},
},
mockStatusCode: 200,
mockResponse: `{"message": "success"}`,
expectError: false,
},
{
name: "validation error",
input: &NewAppInput{
Region: "us-west",
App: App{
Key: AppKey{
Organization: "",
Name: "testapp",
Version: "1.0.0",
},
},
},
mockStatusCode: 400,
mockResponse: `{"message": "organization is required"}`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/CreateApp", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
WithAuthProvider(NewStaticTokenProvider("test-token")),
)
// Execute test
ctx := context.Background()
err := client.CreateApp(ctx, tt.input)
// Verify results
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestShowApp(t *testing.T) {
tests := []struct {
name string
appKey AppKey
region string
mockStatusCode int
mockResponse string
expectError bool
expectNotFound bool
}{
{
name: "successful show",
appKey: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
region: "us-west",
mockStatusCode: 200,
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testapp", "version": "1.0.0"}, "deployment": "kubernetes"}}
`,
expectError: false,
expectNotFound: false,
},
{
name: "app not found",
appKey: AppKey{
Organization: "testorg",
Name: "nonexistent",
Version: "1.0.0",
},
region: "us-west",
mockStatusCode: 404,
mockResponse: "",
expectError: true,
expectNotFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" {
_, _ = w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
)
// Execute test
ctx := context.Background()
app, err := client.ShowApp(ctx, tt.appKey, tt.region)
// Verify results
if tt.expectError {
assert.Error(t, err)
if tt.expectNotFound {
assert.Contains(t, err.Error(), "resource not found")
}
} else {
require.NoError(t, err)
assert.Equal(t, tt.appKey.Organization, app.Key.Organization)
assert.Equal(t, tt.appKey.Name, app.Key.Name)
assert.Equal(t, tt.appKey.Version, app.Key.Version)
}
})
}
}
func TestShowApps(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path)
// Verify request body
var filter AppFilter
err := json.NewDecoder(r.Body).Decode(&filter)
require.NoError(t, err)
assert.Equal(t, "testorg", filter.App.Key.Organization)
assert.Equal(t, "us-west", filter.Region)
// Return multiple apps
response := `{"data": {"key": {"organization": "testorg", "name": "app1", "version": "1.0.0"}, "deployment": "kubernetes"}}
{"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}}
`
w.WriteHeader(200)
_, _ = w.Write([]byte(response))
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
apps, err := client.ShowApps(ctx, AppKey{Organization: "testorg"}, "us-west")
require.NoError(t, err)
assert.Len(t, apps, 2)
assert.Equal(t, "app1", apps[0].Key.Name)
assert.Equal(t, "app2", apps[1].Key.Name)
}
func TestUpdateApp(t *testing.T) {
tests := []struct {
name string
input *UpdateAppInput
mockStatusCode int
mockResponse string
expectError bool
}{
{
name: "successful update",
input: &UpdateAppInput{
Region: "us-west",
App: App{
Key: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
Deployment: "kubernetes",
ImagePath: "nginx:latest",
},
},
mockStatusCode: 200,
mockResponse: `{"message": "success"}`,
expectError: false,
},
{
name: "validation error",
input: &UpdateAppInput{
Region: "us-west",
App: App{
Key: AppKey{
Organization: "",
Name: "testapp",
Version: "1.0.0",
},
},
},
mockStatusCode: 400,
mockResponse: `{"message": "organization is required"}`,
expectError: true,
},
{
name: "app not found",
input: &UpdateAppInput{
Region: "us-west",
App: App{
Key: AppKey{
Organization: "testorg",
Name: "nonexistent",
Version: "1.0.0",
},
},
},
mockStatusCode: 404,
mockResponse: `{"message": "app not found"}`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/UpdateApp", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
// Verify request body
var input UpdateAppInput
err := json.NewDecoder(r.Body).Decode(&input)
require.NoError(t, err)
assert.Equal(t, tt.input.Region, input.Region)
assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization)
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
WithAuthProvider(NewStaticTokenProvider("test-token")),
)
// Execute test
ctx := context.Background()
err := client.UpdateApp(ctx, tt.input)
// Verify results
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestDeleteApp(t *testing.T) {
tests := []struct {
name string
appKey AppKey
region string
mockStatusCode int
expectError bool
}{
{
name: "successful deletion",
appKey: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
region: "us-west",
mockStatusCode: 200,
expectError: false,
},
{
name: "already deleted (404 ok)",
appKey: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
region: "us-west",
mockStatusCode: 404,
expectError: false,
},
{
name: "server error",
appKey: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
region: "us-west",
mockStatusCode: 500,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/DeleteApp", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
err := client.DeleteApp(ctx, tt.appKey, tt.region)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestClientOptions(t *testing.T) {
t.Run("with auth provider", func(t *testing.T) {
authProvider := NewStaticTokenProvider("test-token")
client := NewClient("https://example.com",
WithAuthProvider(authProvider),
)
assert.Equal(t, authProvider, client.AuthProvider)
})
t.Run("with custom HTTP client", func(t *testing.T) {
httpClient := &http.Client{Timeout: 10 * time.Second}
client := NewClient("https://example.com",
WithHTTPClient(httpClient),
)
assert.Equal(t, httpClient, client.HTTPClient)
})
t.Run("with retry options", func(t *testing.T) {
retryOpts := RetryOptions{MaxRetries: 5}
client := NewClient("https://example.com",
WithRetryOptions(retryOpts),
)
assert.Equal(t, 5, client.RetryOpts.MaxRetries)
})
}
func TestAPIError(t *testing.T) {
err := &APIError{
StatusCode: 400,
Messages: []string{"validation failed", "name is required"},
}
assert.Contains(t, err.Error(), "validation failed")
assert.Equal(t, 400, err.StatusCode)
assert.Len(t, err.Messages, 2)
}

View file

@ -1,226 +0,0 @@
// ABOUTME: Unit tests for authentication providers including username/password token flow
// ABOUTME: Tests token caching, login flow, and error conditions with mock servers
package edgeconnect
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStaticTokenProvider(t *testing.T) {
provider := NewStaticTokenProvider("test-token-123")
req, _ := http.NewRequest("GET", "https://example.com", nil)
ctx := context.Background()
err := provider.Attach(ctx, req)
require.NoError(t, err)
assert.Equal(t, "Bearer test-token-123", req.Header.Get("Authorization"))
}
func TestStaticTokenProvider_EmptyToken(t *testing.T) {
provider := NewStaticTokenProvider("")
req, _ := http.NewRequest("GET", "https://example.com", nil)
ctx := context.Background()
err := provider.Attach(ctx, req)
require.NoError(t, err)
assert.Empty(t, req.Header.Get("Authorization"))
}
func TestUsernamePasswordProvider_Success(t *testing.T) {
// Mock login server
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/login", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
// Verify request body
var creds map[string]string
err := json.NewDecoder(r.Body).Decode(&creds)
require.NoError(t, err)
assert.Equal(t, "testuser", creds["username"])
assert.Equal(t, "testpass", creds["password"])
// Return token
response := map[string]string{"token": "dynamic-token-456"}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}))
defer loginServer.Close()
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
ctx := context.Background()
err := provider.Attach(ctx, req)
require.NoError(t, err)
assert.Equal(t, "Bearer dynamic-token-456", req.Header.Get("Authorization"))
}
func TestUsernamePasswordProvider_LoginFailure(t *testing.T) {
// Mock login server that returns error
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("Invalid credentials"))
}))
defer loginServer.Close()
provider := NewUsernamePasswordProvider(loginServer.URL, "baduser", "badpass", nil)
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
ctx := context.Background()
err := provider.Attach(ctx, req)
require.Error(t, err)
assert.Contains(t, err.Error(), "login failed with status 401")
assert.Contains(t, err.Error(), "Invalid credentials")
}
func TestUsernamePasswordProvider_TokenCaching(t *testing.T) {
callCount := 0
// Mock login server that tracks calls
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
response := map[string]string{"token": "cached-token-789"}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}))
defer loginServer.Close()
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
ctx := context.Background()
// First request should call login
req1, _ := http.NewRequest("GET", "https://api.example.com", nil)
err1 := provider.Attach(ctx, req1)
require.NoError(t, err1)
assert.Equal(t, "Bearer cached-token-789", req1.Header.Get("Authorization"))
assert.Equal(t, 1, callCount)
// Second request should use cached token (no additional login call)
req2, _ := http.NewRequest("GET", "https://api.example.com", nil)
err2 := provider.Attach(ctx, req2)
require.NoError(t, err2)
assert.Equal(t, "Bearer cached-token-789", req2.Header.Get("Authorization"))
assert.Equal(t, 1, callCount) // Still only 1 call
}
func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) {
callCount := 0
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
response := map[string]string{"token": "refreshed-token-999"}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}))
defer loginServer.Close()
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
// Manually set expired token
provider.mu.Lock()
provider.cachedToken = "expired-token"
provider.tokenExpiry = time.Now().Add(-1 * time.Hour) // Already expired
provider.mu.Unlock()
ctx := context.Background()
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
err := provider.Attach(ctx, req)
require.NoError(t, err)
assert.Equal(t, "Bearer refreshed-token-999", req.Header.Get("Authorization"))
assert.Equal(t, 1, callCount) // New token retrieved
}
func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) {
callCount := 0
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
response := map[string]string{"token": "new-token-after-invalidation"}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}))
defer loginServer.Close()
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
ctx := context.Background()
// First request to get token
req1, _ := http.NewRequest("GET", "https://api.example.com", nil)
err1 := provider.Attach(ctx, req1)
require.NoError(t, err1)
assert.Equal(t, 1, callCount)
// Invalidate token
provider.InvalidateToken()
// Next request should get new token
req2, _ := http.NewRequest("GET", "https://api.example.com", nil)
err2 := provider.Attach(ctx, req2)
require.NoError(t, err2)
assert.Equal(t, "Bearer new-token-after-invalidation", req2.Header.Get("Authorization"))
assert.Equal(t, 2, callCount) // New login call made
}
func TestUsernamePasswordProvider_BadJSONResponse(t *testing.T) {
// Mock server returning invalid JSON
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("invalid json response"))
}))
defer loginServer.Close()
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
ctx := context.Background()
err := provider.Attach(ctx, req)
require.Error(t, err)
assert.Contains(t, err.Error(), "error parsing JSON")
}
func TestNoAuthProvider(t *testing.T) {
provider := NewNoAuthProvider()
req, _ := http.NewRequest("GET", "https://example.com", nil)
ctx := context.Background()
err := provider.Attach(ctx, req)
require.NoError(t, err)
assert.Empty(t, req.Header.Get("Authorization"))
}
func TestNewClientWithCredentials(t *testing.T) {
client := NewClientWithCredentials("https://example.com", "testuser", "testpass")
assert.Equal(t, "https://example.com", client.BaseURL)
// Check that auth provider is UsernamePasswordProvider
authProvider, ok := client.AuthProvider.(*UsernamePasswordProvider)
require.True(t, ok, "AuthProvider should be UsernamePasswordProvider")
assert.Equal(t, "testuser", authProvider.Username)
assert.Equal(t, "testpass", authProvider.Password)
assert.Equal(t, "https://example.com", authProvider.BaseURL)
}

View file

@ -1,122 +0,0 @@
// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth
// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations
package edgeconnect
import (
"net/http"
"strings"
"time"
)
// Client represents the EdgeXR Master Controller SDK client
type Client struct {
BaseURL string
HTTPClient *http.Client
AuthProvider AuthProvider
RetryOpts RetryOptions
Logger Logger
}
// RetryOptions configures retry behavior for API calls
type RetryOptions struct {
MaxRetries int
InitialDelay time.Duration
MaxDelay time.Duration
Multiplier float64
RetryableHTTPStatusCodes []int
}
// Logger interface for optional logging
type Logger interface {
Printf(format string, v ...interface{})
}
// DefaultRetryOptions returns sensible default retry configuration
func DefaultRetryOptions() RetryOptions {
return RetryOptions{
MaxRetries: 3,
InitialDelay: 1 * time.Second,
MaxDelay: 30 * time.Second,
Multiplier: 2.0,
RetryableHTTPStatusCodes: []int{
http.StatusRequestTimeout,
http.StatusTooManyRequests,
http.StatusInternalServerError,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout,
},
}
}
// Option represents a configuration option for the client
type Option func(*Client)
// WithHTTPClient sets a custom HTTP client
func WithHTTPClient(client *http.Client) Option {
return func(c *Client) {
c.HTTPClient = client
}
}
// WithAuthProvider sets the authentication provider
func WithAuthProvider(auth AuthProvider) Option {
return func(c *Client) {
c.AuthProvider = auth
}
}
// WithRetryOptions sets retry configuration
func WithRetryOptions(opts RetryOptions) Option {
return func(c *Client) {
c.RetryOpts = opts
}
}
// WithLogger sets a logger for debugging
func WithLogger(logger Logger) Option {
return func(c *Client) {
c.Logger = logger
}
}
// NewClient creates a new EdgeXR SDK client
func NewClient(baseURL string, options ...Option) *Client {
client := &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{Timeout: 30 * time.Second},
AuthProvider: NewNoAuthProvider(),
RetryOpts: DefaultRetryOptions(),
}
for _, opt := range options {
opt(client)
}
return client
}
// NewClientWithCredentials creates a new EdgeXR SDK client with username/password authentication
// This matches the existing client pattern from client/client.go
func NewClientWithCredentials(baseURL, username, password string, options ...Option) *Client {
client := &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{Timeout: 30 * time.Second},
AuthProvider: NewUsernamePasswordProvider(baseURL, username, password, nil),
RetryOpts: DefaultRetryOptions(),
}
for _, opt := range options {
opt(client)
}
return client
}
// logf logs a message if a logger is configured
func (c *Client) logf(format string, v ...interface{}) {
if c.Logger != nil {
c.Logger.Printf(format, v...)
}
}

View file

@ -1,283 +0,0 @@
// ABOUTME: Cloudlet management APIs for EdgeXR Master Controller
// ABOUTME: Provides typed methods for creating, querying, and managing edge cloudlets
package edgeconnect
import (
"context"
"encoding/json"
"fmt"
"net/http"
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
)
// CreateCloudlet creates a new cloudlet in the specified region
// Maps to POST /auth/ctrl/CreateCloudlet
func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/CreateCloudlet"
resp, err := transport.Call(ctx, "POST", url, input)
if err != nil {
return fmt.Errorf("CreateCloudlet failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateCloudlet")
}
c.logf("CreateCloudlet: %s/%s created successfully",
input.Cloudlet.Key.Organization, input.Cloudlet.Key.Name)
return nil
}
// ShowCloudlet retrieves a single cloudlet by key and region
// Maps to POST /auth/ctrl/ShowCloudlet
func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) (Cloudlet, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet"
filter := CloudletFilter{
Cloudlet: Cloudlet{Key: cloudletKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
}
if resp.StatusCode >= 400 {
return Cloudlet{}, c.handleErrorResponse(resp, "ShowCloudlet")
}
// Parse streaming JSON response
var cloudlets []Cloudlet
if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil {
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed to parse response: %w", err)
}
if len(cloudlets) == 0 {
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
}
return cloudlets[0], nil
}
// ShowCloudlets retrieves all cloudlets matching the filter criteria
// Maps to POST /auth/ctrl/ShowCloudlet
func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, region string) ([]Cloudlet, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet"
filter := CloudletFilter{
Cloudlet: Cloudlet{Key: cloudletKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return nil, fmt.Errorf("ShowCloudlets failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowCloudlets")
}
var cloudlets []Cloudlet
if resp.StatusCode == http.StatusNotFound {
return cloudlets, nil // Return empty slice for not found
}
if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil {
return nil, fmt.Errorf("ShowCloudlets failed to parse response: %w", err)
}
c.logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets))
return cloudlets, nil
}
// DeleteCloudlet removes a cloudlet from the specified region
// Maps to POST /auth/ctrl/DeleteCloudlet
func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteCloudlet"
filter := CloudletFilter{
Cloudlet: Cloudlet{Key: cloudletKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return fmt.Errorf("DeleteCloudlet failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return c.handleErrorResponse(resp, "DeleteCloudlet")
}
c.logf("DeleteCloudlet: %s/%s deleted successfully",
cloudletKey.Organization, cloudletKey.Name)
return nil
}
// GetCloudletManifest retrieves the deployment manifest for a cloudlet
// Maps to POST /auth/ctrl/GetCloudletManifest
func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletManifest, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletManifest"
filter := CloudletFilter{
Cloudlet: Cloudlet{Key: cloudletKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return nil, fmt.Errorf("GetCloudletManifest failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w",
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
}
if resp.StatusCode >= 400 {
return nil, c.handleErrorResponse(resp, "GetCloudletManifest")
}
// Parse the response as CloudletManifest
var manifest CloudletManifest
if err := c.parseDirectJSONResponse(resp, &manifest); err != nil {
return nil, fmt.Errorf("GetCloudletManifest failed to parse response: %w", err)
}
c.logf("GetCloudletManifest: retrieved manifest for %s/%s",
cloudletKey.Organization, cloudletKey.Name)
return &manifest, nil
}
// GetCloudletResourceUsage retrieves resource usage information for a cloudlet
// Maps to POST /auth/ctrl/GetCloudletResourceUsage
func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletResourceUsage, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletResourceUsage"
filter := CloudletFilter{
Cloudlet: Cloudlet{Key: cloudletKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w",
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
}
if resp.StatusCode >= 400 {
return nil, c.handleErrorResponse(resp, "GetCloudletResourceUsage")
}
// Parse the response as CloudletResourceUsage
var usage CloudletResourceUsage
if err := c.parseDirectJSONResponse(resp, &usage); err != nil {
return nil, fmt.Errorf("GetCloudletResourceUsage failed to parse response: %w", err)
}
c.logf("GetCloudletResourceUsage: retrieved usage for %s/%s",
cloudletKey.Organization, cloudletKey.Name)
return &usage, nil
}
// parseStreamingCloudletResponse parses the EdgeXR streaming JSON response format for cloudlets
func (c *Client) parseStreamingCloudletResponse(resp *http.Response, result interface{}) error {
var responses []Response[Cloudlet]
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
var response Response[Cloudlet]
if err := json.Unmarshal(line, &response); err != nil {
return err
}
responses = append(responses, response)
return nil
})
if parseErr != nil {
return parseErr
}
// Extract data from responses
var cloudlets []Cloudlet
var messages []string
for _, response := range responses {
if response.HasData() {
cloudlets = append(cloudlets, response.Data)
}
if response.IsMessage() {
messages = append(messages, response.Data.GetMessage())
}
}
// If we have error messages, return them
if len(messages) > 0 {
return &APIError{
StatusCode: resp.StatusCode,
Messages: messages,
}
}
// Set result based on type
switch v := result.(type) {
case *[]Cloudlet:
*v = cloudlets
default:
return fmt.Errorf("unsupported result type: %T", result)
}
return nil
}
// parseDirectJSONResponse parses a direct JSON response (not streaming)
func (c *Client) parseDirectJSONResponse(resp *http.Response, result interface{}) error {
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(result); err != nil {
return fmt.Errorf("failed to decode JSON response: %w", err)
}
return nil
}

View file

@ -1,408 +0,0 @@
// ABOUTME: Unit tests for Cloudlet management APIs using httptest mock server
// ABOUTME: Tests create, show, list, delete, manifest, and resource usage operations
package edgeconnect
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateCloudlet(t *testing.T) {
tests := []struct {
name string
input *NewCloudletInput
mockStatusCode int
mockResponse string
expectError bool
}{
{
name: "successful creation",
input: &NewCloudletInput{
Region: "us-west",
Cloudlet: Cloudlet{
Key: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
Location: Location{
Latitude: 37.7749,
Longitude: -122.4194,
},
IpSupport: "IpSupportDynamic",
NumDynamicIps: 10,
},
},
mockStatusCode: 200,
mockResponse: `{"message": "success"}`,
expectError: false,
},
{
name: "validation error",
input: &NewCloudletInput{
Region: "us-west",
Cloudlet: Cloudlet{
Key: CloudletKey{
Organization: "",
Name: "testcloudlet",
},
},
},
mockStatusCode: 400,
mockResponse: `{"message": "organization is required"}`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/CreateCloudlet", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
WithAuthProvider(NewStaticTokenProvider("test-token")),
)
// Execute test
ctx := context.Background()
err := client.CreateCloudlet(ctx, tt.input)
// Verify results
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestShowCloudlet(t *testing.T) {
tests := []struct {
name string
cloudletKey CloudletKey
region string
mockStatusCode int
mockResponse string
expectError bool
expectNotFound bool
}{
{
name: "successful show",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
region: "us-west",
mockStatusCode: 200,
mockResponse: `{"data": {"key": {"organization": "cloudletorg", "name": "testcloudlet"}, "state": "Ready", "location": {"latitude": 37.7749, "longitude": -122.4194}}}
`,
expectError: false,
expectNotFound: false,
},
{
name: "cloudlet not found",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "nonexistent",
},
region: "us-west",
mockStatusCode: 404,
mockResponse: "",
expectError: true,
expectNotFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" {
_, _ = w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
)
// Execute test
ctx := context.Background()
cloudlet, err := client.ShowCloudlet(ctx, tt.cloudletKey, tt.region)
// Verify results
if tt.expectError {
assert.Error(t, err)
if tt.expectNotFound {
assert.Contains(t, err.Error(), "resource not found")
}
} else {
require.NoError(t, err)
assert.Equal(t, tt.cloudletKey.Organization, cloudlet.Key.Organization)
assert.Equal(t, tt.cloudletKey.Name, cloudlet.Key.Name)
assert.Equal(t, "Ready", cloudlet.State)
}
})
}
}
func TestShowCloudlets(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path)
// Verify request body
var filter CloudletFilter
err := json.NewDecoder(r.Body).Decode(&filter)
require.NoError(t, err)
assert.Equal(t, "cloudletorg", filter.Cloudlet.Key.Organization)
assert.Equal(t, "us-west", filter.Region)
// Return multiple cloudlets
response := `{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet1"}, "state": "Ready"}}
{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}}
`
w.WriteHeader(200)
_, _ = w.Write([]byte(response))
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
cloudlets, err := client.ShowCloudlets(ctx, CloudletKey{Organization: "cloudletorg"}, "us-west")
require.NoError(t, err)
assert.Len(t, cloudlets, 2)
assert.Equal(t, "cloudlet1", cloudlets[0].Key.Name)
assert.Equal(t, "Ready", cloudlets[0].State)
assert.Equal(t, "cloudlet2", cloudlets[1].Key.Name)
assert.Equal(t, "Creating", cloudlets[1].State)
}
func TestDeleteCloudlet(t *testing.T) {
tests := []struct {
name string
cloudletKey CloudletKey
region string
mockStatusCode int
expectError bool
}{
{
name: "successful deletion",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
region: "us-west",
mockStatusCode: 200,
expectError: false,
},
{
name: "already deleted (404 ok)",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
region: "us-west",
mockStatusCode: 404,
expectError: false,
},
{
name: "server error",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
region: "us-west",
mockStatusCode: 500,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/DeleteCloudlet", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
err := client.DeleteCloudlet(ctx, tt.cloudletKey, tt.region)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestGetCloudletManifest(t *testing.T) {
tests := []struct {
name string
cloudletKey CloudletKey
region string
mockStatusCode int
mockResponse string
expectError bool
expectNotFound bool
}{
{
name: "successful manifest retrieval",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
region: "us-west",
mockStatusCode: 200,
mockResponse: `{"manifest": "apiVersion: v1\nkind: Deployment\nmetadata:\n name: test", "last_modified": "2024-01-01T00:00:00Z"}`,
expectError: false,
expectNotFound: false,
},
{
name: "manifest not found",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "nonexistent",
},
region: "us-west",
mockStatusCode: 404,
mockResponse: "",
expectError: true,
expectNotFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletManifest", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" {
_, _ = w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
manifest, err := client.GetCloudletManifest(ctx, tt.cloudletKey, tt.region)
if tt.expectError {
assert.Error(t, err)
if tt.expectNotFound {
assert.Contains(t, err.Error(), "resource not found")
}
} else {
require.NoError(t, err)
assert.NotNil(t, manifest)
assert.Contains(t, manifest.Manifest, "apiVersion: v1")
}
})
}
}
func TestGetCloudletResourceUsage(t *testing.T) {
tests := []struct {
name string
cloudletKey CloudletKey
region string
mockStatusCode int
mockResponse string
expectError bool
expectNotFound bool
}{
{
name: "successful usage retrieval",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
region: "us-west",
mockStatusCode: 200,
mockResponse: `{"cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}, "region": "us-west", "usage": {"cpu": "50%", "memory": "30%", "disk": "20%"}}`,
expectError: false,
expectNotFound: false,
},
{
name: "usage not found",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "nonexistent",
},
region: "us-west",
mockStatusCode: 404,
mockResponse: "",
expectError: true,
expectNotFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletResourceUsage", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" {
_, _ = w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
usage, err := client.GetCloudletResourceUsage(ctx, tt.cloudletKey, tt.region)
if tt.expectError {
assert.Error(t, err)
if tt.expectNotFound {
assert.Contains(t, err.Error(), "resource not found")
}
} else {
require.NoError(t, err)
assert.NotNil(t, usage)
assert.Equal(t, "cloudletorg", usage.CloudletKey.Organization)
assert.Equal(t, "testcloudlet", usage.CloudletKey.Name)
assert.Equal(t, "us-west", usage.Region)
assert.Contains(t, usage.Usage, "cpu")
}
})
}
}

View file

@ -1,293 +0,0 @@
// ABOUTME: Application instance lifecycle management APIs for EdgeXR Master Controller
// ABOUTME: Provides typed methods for creating, querying, and deleting application instances
package v2
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// CreateAppInstance creates a new application instance in the specified region
// Maps to POST /auth/ctrl/CreateAppInst
func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInput) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/CreateAppInst"
resp, err := transport.Call(ctx, "POST", url, input)
if err != nil {
return fmt.Errorf("CreateAppInstance failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateAppInstance")
}
// Parse streaming JSON response
if _, err = parseStreamingResponse[AppInstance](resp); err != nil {
return fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
}
c.logf("CreateAppInstance: %s/%s created successfully",
input.AppInst.Key.Organization, input.AppInst.Key.Name)
return nil
}
// ShowAppInstance retrieves a single application instance by key and region
// Maps to POST /auth/ctrl/ShowAppInst
func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) (AppInstance, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
filter := AppInstanceFilter{
AppInstance: AppInstance{Key: appInstKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return AppInstance{}, fmt.Errorf("app instance %s/%s: %w",
appInstKey.Organization, appInstKey.Name, ErrResourceNotFound)
}
if resp.StatusCode >= 400 {
return AppInstance{}, c.handleErrorResponse(resp, "ShowAppInstance")
}
// Parse streaming JSON response
var appInstances []AppInstance
if appInstances, err = parseStreamingResponse[AppInstance](resp); err != nil {
return AppInstance{}, fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
}
if len(appInstances) == 0 {
return AppInstance{}, fmt.Errorf("app instance %s/%s in region %s: %w",
appInstKey.Organization, appInstKey.Name, region, ErrResourceNotFound)
}
return appInstances[0], nil
}
// ShowAppInstances retrieves all application instances matching the filter criteria
// Maps to POST /auth/ctrl/ShowAppInst
func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) ([]AppInstance, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
filter := AppInstanceFilter{
AppInstance: AppInstance{Key: appInstKey, AppKey: appKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowAppInstances")
}
if resp.StatusCode == http.StatusNotFound {
return []AppInstance{}, nil // Return empty slice for not found
}
var appInstances []AppInstance
if appInstances, err = parseStreamingResponse[AppInstance](resp); err != nil {
return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err)
}
c.logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances))
return appInstances, nil
}
// UpdateAppInstance updates an application instance and then refreshes it
// Maps to POST /auth/ctrl/UpdateAppInst
func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstanceInput) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/UpdateAppInst"
resp, err := transport.Call(ctx, "POST", url, input)
if err != nil {
return fmt.Errorf("UpdateAppInstance failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "UpdateAppInstance")
}
c.logf("UpdateAppInstance: %s/%s updated successfully",
input.AppInst.Key.Organization, input.AppInst.Key.Name)
return nil
}
// RefreshAppInstance refreshes an application instance's state
// Maps to POST /auth/ctrl/RefreshAppInst
func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/RefreshAppInst"
filter := AppInstanceFilter{
AppInstance: AppInstance{Key: appInstKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return fmt.Errorf("RefreshAppInstance failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "RefreshAppInstance")
}
c.logf("RefreshAppInstance: %s/%s refreshed successfully",
appInstKey.Organization, appInstKey.Name)
return nil
}
// DeleteAppInstance removes an application instance
// Maps to POST /auth/ctrl/DeleteAppInst
func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst"
input := DeleteAppInstanceInput{
Region: region,
}
input.AppInst.Key = appInstKey
resp, err := transport.Call(ctx, "POST", url, input)
if err != nil {
return fmt.Errorf("DeleteAppInstance failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return c.handleErrorResponse(resp, "DeleteAppInstance")
}
c.logf("DeleteAppInstance: %s/%s deleted successfully",
appInstKey.Organization, appInstKey.Name)
return nil
}
// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances
func parseStreamingResponse[T Message](resp *http.Response) ([]T, error) {
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return []T{}, fmt.Errorf("failed to read response body: %w", err)
}
// todo finish check the responses, test them, and make a unify result, probably need
// to update the response parameter to the message type e.g. App or AppInst
isV2, err := isV2Response(bodyBytes)
if err != nil {
return []T{}, fmt.Errorf("failed to parse streaming response: %w", err)
}
if isV2 {
resultV2, err := parseStreamingResponseV2[T](resp.StatusCode, bodyBytes)
if err != nil {
return []T{}, err
}
return resultV2, nil
}
resultV1, err := parseStreamingResponseV1[T](resp.StatusCode, bodyBytes)
if err != nil {
return nil, err
}
if !resultV1.IsSuccessful() {
return []T{}, resultV1.Error()
}
return resultV1.GetData(), nil
}
func parseStreamingResponseV1[T Message](statusCode int, bodyBytes []byte) (Responses[T], error) {
// Fall back to streaming format (v1 API format)
var responses Responses[T]
responses.StatusCode = statusCode
decoder := json.NewDecoder(bytes.NewReader(bodyBytes))
for {
var d Response[T]
if err := decoder.Decode(&d); err != nil {
if err.Error() == "EOF" {
break
}
return Responses[T]{}, fmt.Errorf("error in parsing json object into Message: %w", err)
}
if d.Result.Message != "" && d.Result.Code != 0 {
responses.StatusCode = d.Result.Code
}
if strings.Contains(d.Data.GetMessage(), "CreateError") {
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "CreateError"))
}
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

@ -1,527 +0,0 @@
// ABOUTME: Unit tests for AppInstance management APIs using httptest mock server
// ABOUTME: Tests create, show, list, refresh, and delete operations with error conditions
package v2
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateAppInstance(t *testing.T) {
tests := []struct {
name string
input *NewAppInstanceInput
mockStatusCode int
mockResponse string
expectError bool
errorContains string
}{
{
name: "successful creation",
input: &NewAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
AppKey: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
Flavor: Flavor{Name: "m4.small"},
},
},
mockStatusCode: 200,
mockResponse: `{"message": "success"}`,
expectError: false,
},
{
name: "validation error",
input: &NewAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "",
Name: "testinst",
},
},
},
mockStatusCode: 400,
mockResponse: `{"message": "organization is required"}`,
expectError: true,
},
{
name: "HTTP 200 with CreateError message",
input: &NewAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
Flavor: Flavor{Name: "m4.small"},
},
},
mockStatusCode: 200,
mockResponse: `{"data":{"message":"Creating"}}
{"data":{"message":"a service has been configured"}}
{"data":{"message":"CreateError"}}
{"data":{"message":"Deleting AppInst due to failure"}}
{"data":{"message":"Deleted AppInst successfully"}}`,
expectError: true,
errorContains: "CreateError",
},
{
name: "HTTP 200 with result error code",
input: &NewAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
Flavor: Flavor{Name: "m4.small"},
},
},
mockStatusCode: 200,
mockResponse: `{"data":{"message":"Creating"}}
{"data":{"message":"a service has been configured"}}
{"data":{"message":"CreateError"}}
{"data":{"message":"Deleting AppInst due to failure"}}
{"data":{"message":"Deleted AppInst successfully"}}
{"result":{"message":"Encountered failures: Create App Inst failed: deployments.apps is forbidden: User \"system:serviceaccount:edgexr:crm-telekomop-munich\" cannot create resource \"deployments\" in API group \"apps\" in the namespace \"gitea\"","code":400}}`,
expectError: true,
errorContains: "deployments.apps is forbidden",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/CreateAppInst", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
WithAuthProvider(NewStaticTokenProvider("test-token")),
)
// Execute test
ctx := context.Background()
err := client.CreateAppInstance(ctx, tt.input)
// Verify results
if tt.expectError {
assert.Error(t, err)
if tt.errorContains != "" {
assert.Contains(t, err.Error(), tt.errorContains)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestShowAppInstance(t *testing.T) {
tests := []struct {
name string
appInstKey AppInstanceKey
appKey AppKey
region string
mockStatusCode int
mockResponse string
expectError bool
expectNotFound bool
}{
{
name: "successful show",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
appKey: AppKey{Name: "testapp"},
region: "us-west",
mockStatusCode: 200,
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}}
`,
expectError: false,
expectNotFound: false,
},
{
name: "instance not found",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "nonexistent",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
appKey: AppKey{Name: "testapp"},
region: "us-west",
mockStatusCode: 404,
mockResponse: "",
expectError: true,
expectNotFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" {
_, _ = w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
)
// Execute test
ctx := context.Background()
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region)
// Verify results
if tt.expectError {
assert.Error(t, err)
if tt.expectNotFound {
assert.Contains(t, err.Error(), "resource not found")
}
} else {
require.NoError(t, err)
assert.Equal(t, tt.appInstKey.Organization, appInst.Key.Organization)
assert.Equal(t, tt.appInstKey.Name, appInst.Key.Name)
assert.Equal(t, "Ready", appInst.State)
}
})
}
}
func TestShowAppInstances(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path)
// Verify request body
var filter AppInstanceFilter
err := json.NewDecoder(r.Body).Decode(&filter)
require.NoError(t, err)
assert.Equal(t, "testorg", filter.AppInstance.Key.Organization)
assert.Equal(t, "us-west", filter.Region)
// Return multiple app instances
response := `{"data": {"key": {"organization": "testorg", "name": "inst1"}, "state": "Ready"}}
{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}}
`
w.WriteHeader(200)
_, _ = w.Write([]byte(response))
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west")
require.NoError(t, err)
assert.Len(t, appInstances, 2)
assert.Equal(t, "inst1", appInstances[0].Key.Name)
assert.Equal(t, "Ready", appInstances[0].State)
assert.Equal(t, "inst2", appInstances[1].Key.Name)
assert.Equal(t, "Creating", appInstances[1].State)
}
func TestUpdateAppInstance(t *testing.T) {
tests := []struct {
name string
input *UpdateAppInstanceInput
mockStatusCode int
mockResponse string
expectError bool
}{
{
name: "successful update",
input: &UpdateAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
AppKey: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
Flavor: Flavor{Name: "m4.medium"},
PowerState: "PowerOn",
},
},
mockStatusCode: 200,
mockResponse: `{"message": "success"}`,
expectError: false,
},
{
name: "validation error",
input: &UpdateAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
},
},
mockStatusCode: 400,
mockResponse: `{"message": "organization is required"}`,
expectError: true,
},
{
name: "instance not found",
input: &UpdateAppInstanceInput{
Region: "us-west",
AppInst: AppInstance{
Key: AppInstanceKey{
Organization: "testorg",
Name: "nonexistent",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
},
},
mockStatusCode: 404,
mockResponse: `{"message": "app instance not found"}`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/UpdateAppInst", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
// Verify request body
var input UpdateAppInstanceInput
err := json.NewDecoder(r.Body).Decode(&input)
require.NoError(t, err)
assert.Equal(t, tt.input.Region, input.Region)
assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization)
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
WithAuthProvider(NewStaticTokenProvider("test-token")),
)
// Execute test
ctx := context.Background()
err := client.UpdateAppInstance(ctx, tt.input)
// Verify results
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestRefreshAppInstance(t *testing.T) {
tests := []struct {
name string
appInstKey AppInstanceKey
region string
mockStatusCode int
expectError bool
}{
{
name: "successful refresh",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
region: "us-west",
mockStatusCode: 200,
expectError: false,
},
{
name: "server error",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
region: "us-west",
mockStatusCode: 500,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/RefreshAppInst", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
err := client.RefreshAppInstance(ctx, tt.appInstKey, tt.region)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestDeleteAppInstance(t *testing.T) {
tests := []struct {
name string
appInstKey AppInstanceKey
region string
mockStatusCode int
expectError bool
}{
{
name: "successful deletion",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
region: "us-west",
mockStatusCode: 200,
expectError: false,
},
{
name: "already deleted (404 ok)",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
region: "us-west",
mockStatusCode: 404,
expectError: false,
},
{
name: "server error",
appInstKey: AppInstanceKey{
Organization: "testorg",
Name: "testinst",
CloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
},
region: "us-west",
mockStatusCode: 500,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/DeleteAppInst", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
err := client.DeleteAppInstance(ctx, tt.appInstKey, tt.region)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

View file

@ -1,213 +0,0 @@
// ABOUTME: Application lifecycle management APIs for EdgeXR Master Controller
// ABOUTME: Provides typed methods for creating, querying, and deleting applications
package v2
import (
"context"
"fmt"
"io"
"net/http"
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
)
var (
// ErrResourceNotFound indicates the requested resource was not found
ErrResourceNotFound = fmt.Errorf("resource not found")
)
// CreateApp creates a new application in the specified region
// Maps to POST /auth/ctrl/CreateApp
func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/CreateApp"
resp, err := transport.Call(ctx, "POST", url, input)
if err != nil {
return fmt.Errorf("CreateApp failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateApp")
}
c.logf("CreateApp: %s/%s version %s created successfully",
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
return nil
}
// ShowApp retrieves a single application by key and region
// Maps to POST /auth/ctrl/ShowApp
func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp"
filter := AppFilter{
App: App{Key: appKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return App{}, fmt.Errorf("ShowApp failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound)
}
if resp.StatusCode >= 400 {
return App{}, c.handleErrorResponse(resp, "ShowApp")
}
// Parse streaming JSON response
var apps []App
if apps, err = parseStreamingResponse[App](resp); err != nil {
return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err)
}
if len(apps) == 0 {
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound)
}
return apps[0], nil
}
// ShowApps retrieves all applications matching the filter criteria
// Maps to POST /auth/ctrl/ShowApp
func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]App, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp"
filter := AppFilter{
App: App{Key: appKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return nil, fmt.Errorf("ShowApps failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowApps")
}
if resp.StatusCode == http.StatusNotFound {
return []App{}, nil // Return empty slice for not found
}
var apps []App
if apps, err = parseStreamingResponse[App](resp); err != nil {
return nil, fmt.Errorf("ShowApps failed to parse response: %w", err)
}
c.logf("ShowApps: found %d apps matching criteria", len(apps))
return apps, nil
}
// UpdateApp updates the definition of an application
// Maps to POST /auth/ctrl/UpdateApp
func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/UpdateApp"
resp, err := transport.Call(ctx, "POST", url, input)
if err != nil {
return fmt.Errorf("UpdateApp failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "UpdateApp")
}
c.logf("UpdateApp: %s/%s version %s updated successfully",
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
return nil
}
// DeleteApp removes an application from the specified region
// Maps to POST /auth/ctrl/DeleteApp
func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp"
input := DeleteAppInput{
Region: region,
}
input.App.Key = appKey
resp, err := transport.Call(ctx, "POST", url, input)
if err != nil {
return fmt.Errorf("DeleteApp failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return c.handleErrorResponse(resp, "DeleteApp")
}
c.logf("DeleteApp: %s/%s version %s deleted successfully",
appKey.Organization, appKey.Name, appKey.Version)
return nil
}
// getTransport creates an HTTP transport with current client settings
func (c *Client) getTransport() *sdkhttp.Transport {
return sdkhttp.NewTransport(
sdkhttp.RetryOptions{
MaxRetries: c.RetryOpts.MaxRetries,
InitialDelay: c.RetryOpts.InitialDelay,
MaxDelay: c.RetryOpts.MaxDelay,
Multiplier: c.RetryOpts.Multiplier,
RetryableHTTPStatusCodes: c.RetryOpts.RetryableHTTPStatusCodes,
},
c.AuthProvider,
c.Logger,
)
}
// handleErrorResponse creates an appropriate error from HTTP error response
func (c *Client) handleErrorResponse(resp *http.Response, operation string) error {
messages := []string{
fmt.Sprintf("%s failed with status %d", operation, resp.StatusCode),
}
bodyBytes := []byte{}
if resp.Body != nil {
defer func() {
_ = resp.Body.Close()
}()
bodyBytes, _ = io.ReadAll(resp.Body)
messages = append(messages, string(bodyBytes))
}
return &APIError{
StatusCode: resp.StatusCode,
Messages: messages,
Body: bodyBytes,
}
}

View file

@ -1,409 +0,0 @@
// ABOUTME: Unit tests for App management APIs using httptest mock server
// ABOUTME: Tests create, show, list, and delete operations with error conditions
package v2
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateApp(t *testing.T) {
tests := []struct {
name string
input *NewAppInput
mockStatusCode int
mockResponse string
expectError bool
}{
{
name: "successful creation",
input: &NewAppInput{
Region: "us-west",
App: App{
Key: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
Deployment: "kubernetes",
},
},
mockStatusCode: 200,
mockResponse: `{"message": "success"}`,
expectError: false,
},
{
name: "validation error",
input: &NewAppInput{
Region: "us-west",
App: App{
Key: AppKey{
Organization: "",
Name: "testapp",
Version: "1.0.0",
},
},
},
mockStatusCode: 400,
mockResponse: `{"message": "organization is required"}`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/CreateApp", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
WithAuthProvider(NewStaticTokenProvider("test-token")),
)
// Execute test
ctx := context.Background()
err := client.CreateApp(ctx, tt.input)
// Verify results
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestShowApp(t *testing.T) {
tests := []struct {
name string
appKey AppKey
region string
mockStatusCode int
mockResponse string
expectError bool
expectNotFound bool
}{
{
name: "successful show",
appKey: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
region: "us-west",
mockStatusCode: 200,
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testapp", "version": "1.0.0"}, "deployment": "kubernetes"}}
`,
expectError: false,
expectNotFound: false,
},
{
name: "app not found",
appKey: AppKey{
Organization: "testorg",
Name: "nonexistent",
Version: "1.0.0",
},
region: "us-west",
mockStatusCode: 404,
mockResponse: "",
expectError: true,
expectNotFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" {
_, _ = w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
)
// Execute test
ctx := context.Background()
app, err := client.ShowApp(ctx, tt.appKey, tt.region)
// Verify results
if tt.expectError {
assert.Error(t, err)
if tt.expectNotFound {
assert.Contains(t, err.Error(), "resource not found")
}
} else {
require.NoError(t, err)
assert.Equal(t, tt.appKey.Organization, app.Key.Organization)
assert.Equal(t, tt.appKey.Name, app.Key.Name)
assert.Equal(t, tt.appKey.Version, app.Key.Version)
}
})
}
}
func TestShowApps(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path)
// Verify request body
var filter AppFilter
err := json.NewDecoder(r.Body).Decode(&filter)
require.NoError(t, err)
assert.Equal(t, "testorg", filter.App.Key.Organization)
assert.Equal(t, "us-west", filter.Region)
// Return multiple apps
response := `{"data": {"key": {"organization": "testorg", "name": "app1", "version": "1.0.0"}, "deployment": "kubernetes"}}
{"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}}
`
w.WriteHeader(200)
_, _ = w.Write([]byte(response))
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
apps, err := client.ShowApps(ctx, AppKey{Organization: "testorg"}, "us-west")
require.NoError(t, err)
assert.Len(t, apps, 2)
assert.Equal(t, "app1", apps[0].Key.Name)
assert.Equal(t, "app2", apps[1].Key.Name)
}
func TestUpdateApp(t *testing.T) {
tests := []struct {
name string
input *UpdateAppInput
mockStatusCode int
mockResponse string
expectError bool
}{
{
name: "successful update",
input: &UpdateAppInput{
Region: "us-west",
App: App{
Key: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
Deployment: "kubernetes",
ImagePath: "nginx:latest",
},
},
mockStatusCode: 200,
mockResponse: `{"message": "success"}`,
expectError: false,
},
{
name: "validation error",
input: &UpdateAppInput{
Region: "us-west",
App: App{
Key: AppKey{
Organization: "",
Name: "testapp",
Version: "1.0.0",
},
},
},
mockStatusCode: 400,
mockResponse: `{"message": "organization is required"}`,
expectError: true,
},
{
name: "app not found",
input: &UpdateAppInput{
Region: "us-west",
App: App{
Key: AppKey{
Organization: "testorg",
Name: "nonexistent",
Version: "1.0.0",
},
},
},
mockStatusCode: 404,
mockResponse: `{"message": "app not found"}`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/UpdateApp", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
// Verify request body
var input UpdateAppInput
err := json.NewDecoder(r.Body).Decode(&input)
require.NoError(t, err)
assert.Equal(t, tt.input.Region, input.Region)
assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization)
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
WithAuthProvider(NewStaticTokenProvider("test-token")),
)
// Execute test
ctx := context.Background()
err := client.UpdateApp(ctx, tt.input)
// Verify results
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestDeleteApp(t *testing.T) {
tests := []struct {
name string
appKey AppKey
region string
mockStatusCode int
expectError bool
}{
{
name: "successful deletion",
appKey: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
region: "us-west",
mockStatusCode: 200,
expectError: false,
},
{
name: "already deleted (404 ok)",
appKey: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
region: "us-west",
mockStatusCode: 404,
expectError: false,
},
{
name: "server error",
appKey: AppKey{
Organization: "testorg",
Name: "testapp",
Version: "1.0.0",
},
region: "us-west",
mockStatusCode: 500,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/DeleteApp", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
err := client.DeleteApp(ctx, tt.appKey, tt.region)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestClientOptions(t *testing.T) {
t.Run("with auth provider", func(t *testing.T) {
authProvider := NewStaticTokenProvider("test-token")
client := NewClient("https://example.com",
WithAuthProvider(authProvider),
)
assert.Equal(t, authProvider, client.AuthProvider)
})
t.Run("with custom HTTP client", func(t *testing.T) {
httpClient := &http.Client{Timeout: 10 * time.Second}
client := NewClient("https://example.com",
WithHTTPClient(httpClient),
)
assert.Equal(t, httpClient, client.HTTPClient)
})
t.Run("with retry options", func(t *testing.T) {
retryOpts := RetryOptions{MaxRetries: 5}
client := NewClient("https://example.com",
WithRetryOptions(retryOpts),
)
assert.Equal(t, 5, client.RetryOpts.MaxRetries)
})
}
func TestAPIError(t *testing.T) {
err := &APIError{
StatusCode: 400,
Messages: []string{"validation failed", "name is required"},
}
assert.Contains(t, err.Error(), "validation failed")
assert.Equal(t, 400, err.StatusCode)
assert.Len(t, err.Messages, 2)
}

View file

@ -1,186 +0,0 @@
// ABOUTME: Authentication providers for EdgeXR Master Controller API
// ABOUTME: Supports Bearer token authentication with pluggable provider interface
package v2
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// AuthProvider interface for attaching authentication to requests
type AuthProvider interface {
// Attach adds authentication headers to the request
Attach(ctx context.Context, req *http.Request) error
}
// StaticTokenProvider implements Bearer token authentication with a fixed token
type StaticTokenProvider struct {
Token string
}
// NewStaticTokenProvider creates a new static token provider
func NewStaticTokenProvider(token string) *StaticTokenProvider {
return &StaticTokenProvider{Token: token}
}
// Attach adds the Bearer token to the request Authorization header
func (s *StaticTokenProvider) Attach(ctx context.Context, req *http.Request) error {
if s.Token != "" {
req.Header.Set("Authorization", "Bearer "+s.Token)
}
return nil
}
// UsernamePasswordProvider implements dynamic token retrieval using username/password
// This matches the existing client/client.go RetrieveToken implementation
type UsernamePasswordProvider struct {
BaseURL string
Username string
Password string
HTTPClient *http.Client
// Token caching
mu sync.RWMutex
cachedToken string
tokenExpiry time.Time
}
// NewUsernamePasswordProvider creates a new username/password auth provider
func NewUsernamePasswordProvider(baseURL, username, password string, httpClient *http.Client) *UsernamePasswordProvider {
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
return &UsernamePasswordProvider{
BaseURL: strings.TrimRight(baseURL, "/"),
Username: username,
Password: password,
HTTPClient: httpClient,
}
}
// Attach retrieves a token (with caching) and adds it to the Authorization header
func (u *UsernamePasswordProvider) Attach(ctx context.Context, req *http.Request) error {
token, err := u.getToken(ctx)
if err != nil {
return fmt.Errorf("failed to get token: %w", err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
return nil
}
// getToken retrieves a token, using cache if valid
func (u *UsernamePasswordProvider) getToken(ctx context.Context) (string, error) {
// Check cache first
u.mu.RLock()
if u.cachedToken != "" && time.Now().Before(u.tokenExpiry) {
token := u.cachedToken
u.mu.RUnlock()
return token, nil
}
u.mu.RUnlock()
// Need to retrieve new token
u.mu.Lock()
defer u.mu.Unlock()
// Double-check after acquiring write lock
if u.cachedToken != "" && time.Now().Before(u.tokenExpiry) {
return u.cachedToken, nil
}
// Retrieve token using existing RetrieveToken logic
token, err := u.retrieveToken(ctx)
if err != nil {
return "", err
}
// Cache token with reasonable expiry (assume 1 hour, can be configurable)
u.cachedToken = token
u.tokenExpiry = time.Now().Add(1 * time.Hour)
return token, nil
}
// retrieveToken implements the same logic as the existing client/client.go RetrieveToken method
func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, error) {
// Marshal credentials - same as existing implementation
jsonData, err := json.Marshal(map[string]string{
"username": u.Username,
"password": u.Password,
})
if err != nil {
return "", err
}
// Create request - same as existing implementation
loginURL := u.BaseURL + "/api/v1/login"
request, err := http.NewRequestWithContext(ctx, "POST", loginURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", err
}
request.Header.Set("Content-Type", "application/json")
// Execute request
resp, err := u.HTTPClient.Do(request)
if err != nil {
return "", err
}
defer func() {
_ = resp.Body.Close()
}()
// Read response body - same as existing implementation
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response body: %v", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse JSON response - same as existing implementation
var respData struct {
Token string `json:"token"`
}
err = json.Unmarshal(body, &respData)
if err != nil {
return "", fmt.Errorf("error parsing JSON (status %d): %v", resp.StatusCode, err)
}
return respData.Token, nil
}
// InvalidateToken clears the cached token, forcing a new login on next request
func (u *UsernamePasswordProvider) InvalidateToken() {
u.mu.Lock()
defer u.mu.Unlock()
u.cachedToken = ""
u.tokenExpiry = time.Time{}
}
// NoAuthProvider implements no authentication (for testing or public endpoints)
type NoAuthProvider struct{}
// NewNoAuthProvider creates a new no-auth provider
func NewNoAuthProvider() *NoAuthProvider {
return &NoAuthProvider{}
}
// Attach does nothing (no authentication)
func (n *NoAuthProvider) Attach(ctx context.Context, req *http.Request) error {
return nil
}

View file

@ -1,226 +0,0 @@
// ABOUTME: Unit tests for authentication providers including username/password token flow
// ABOUTME: Tests token caching, login flow, and error conditions with mock servers
package v2
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStaticTokenProvider(t *testing.T) {
provider := NewStaticTokenProvider("test-token-123")
req, _ := http.NewRequest("GET", "https://example.com", nil)
ctx := context.Background()
err := provider.Attach(ctx, req)
require.NoError(t, err)
assert.Equal(t, "Bearer test-token-123", req.Header.Get("Authorization"))
}
func TestStaticTokenProvider_EmptyToken(t *testing.T) {
provider := NewStaticTokenProvider("")
req, _ := http.NewRequest("GET", "https://example.com", nil)
ctx := context.Background()
err := provider.Attach(ctx, req)
require.NoError(t, err)
assert.Empty(t, req.Header.Get("Authorization"))
}
func TestUsernamePasswordProvider_Success(t *testing.T) {
// Mock login server
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/login", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
// Verify request body
var creds map[string]string
err := json.NewDecoder(r.Body).Decode(&creds)
require.NoError(t, err)
assert.Equal(t, "testuser", creds["username"])
assert.Equal(t, "testpass", creds["password"])
// Return token
response := map[string]string{"token": "dynamic-token-456"}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}))
defer loginServer.Close()
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
ctx := context.Background()
err := provider.Attach(ctx, req)
require.NoError(t, err)
assert.Equal(t, "Bearer dynamic-token-456", req.Header.Get("Authorization"))
}
func TestUsernamePasswordProvider_LoginFailure(t *testing.T) {
// Mock login server that returns error
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("Invalid credentials"))
}))
defer loginServer.Close()
provider := NewUsernamePasswordProvider(loginServer.URL, "baduser", "badpass", nil)
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
ctx := context.Background()
err := provider.Attach(ctx, req)
require.Error(t, err)
assert.Contains(t, err.Error(), "login failed with status 401")
assert.Contains(t, err.Error(), "Invalid credentials")
}
func TestUsernamePasswordProvider_TokenCaching(t *testing.T) {
callCount := 0
// Mock login server that tracks calls
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
response := map[string]string{"token": "cached-token-789"}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}))
defer loginServer.Close()
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
ctx := context.Background()
// First request should call login
req1, _ := http.NewRequest("GET", "https://api.example.com", nil)
err1 := provider.Attach(ctx, req1)
require.NoError(t, err1)
assert.Equal(t, "Bearer cached-token-789", req1.Header.Get("Authorization"))
assert.Equal(t, 1, callCount)
// Second request should use cached token (no additional login call)
req2, _ := http.NewRequest("GET", "https://api.example.com", nil)
err2 := provider.Attach(ctx, req2)
require.NoError(t, err2)
assert.Equal(t, "Bearer cached-token-789", req2.Header.Get("Authorization"))
assert.Equal(t, 1, callCount) // Still only 1 call
}
func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) {
callCount := 0
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
response := map[string]string{"token": "refreshed-token-999"}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}))
defer loginServer.Close()
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
// Manually set expired token
provider.mu.Lock()
provider.cachedToken = "expired-token"
provider.tokenExpiry = time.Now().Add(-1 * time.Hour) // Already expired
provider.mu.Unlock()
ctx := context.Background()
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
err := provider.Attach(ctx, req)
require.NoError(t, err)
assert.Equal(t, "Bearer refreshed-token-999", req.Header.Get("Authorization"))
assert.Equal(t, 1, callCount) // New token retrieved
}
func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) {
callCount := 0
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
response := map[string]string{"token": "new-token-after-invalidation"}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}))
defer loginServer.Close()
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
ctx := context.Background()
// First request to get token
req1, _ := http.NewRequest("GET", "https://api.example.com", nil)
err1 := provider.Attach(ctx, req1)
require.NoError(t, err1)
assert.Equal(t, 1, callCount)
// Invalidate token
provider.InvalidateToken()
// Next request should get new token
req2, _ := http.NewRequest("GET", "https://api.example.com", nil)
err2 := provider.Attach(ctx, req2)
require.NoError(t, err2)
assert.Equal(t, "Bearer new-token-after-invalidation", req2.Header.Get("Authorization"))
assert.Equal(t, 2, callCount) // New login call made
}
func TestUsernamePasswordProvider_BadJSONResponse(t *testing.T) {
// Mock server returning invalid JSON
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("invalid json response"))
}))
defer loginServer.Close()
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
ctx := context.Background()
err := provider.Attach(ctx, req)
require.Error(t, err)
assert.Contains(t, err.Error(), "error parsing JSON")
}
func TestNoAuthProvider(t *testing.T) {
provider := NewNoAuthProvider()
req, _ := http.NewRequest("GET", "https://example.com", nil)
ctx := context.Background()
err := provider.Attach(ctx, req)
require.NoError(t, err)
assert.Empty(t, req.Header.Get("Authorization"))
}
func TestNewClientWithCredentials(t *testing.T) {
client := NewClientWithCredentials("https://example.com", "testuser", "testpass")
assert.Equal(t, "https://example.com", client.BaseURL)
// Check that auth provider is UsernamePasswordProvider
authProvider, ok := client.AuthProvider.(*UsernamePasswordProvider)
require.True(t, ok, "AuthProvider should be UsernamePasswordProvider")
assert.Equal(t, "testuser", authProvider.Username)
assert.Equal(t, "testpass", authProvider.Password)
assert.Equal(t, "https://example.com", authProvider.BaseURL)
}

View file

@ -1,122 +0,0 @@
// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth
// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations
package v2
import (
"net/http"
"strings"
"time"
)
// Client represents the EdgeXR Master Controller SDK client
type Client struct {
BaseURL string
HTTPClient *http.Client
AuthProvider AuthProvider
RetryOpts RetryOptions
Logger Logger
}
// RetryOptions configures retry behavior for API calls
type RetryOptions struct {
MaxRetries int
InitialDelay time.Duration
MaxDelay time.Duration
Multiplier float64
RetryableHTTPStatusCodes []int
}
// Logger interface for optional logging
type Logger interface {
Printf(format string, v ...interface{})
}
// DefaultRetryOptions returns sensible default retry configuration
func DefaultRetryOptions() RetryOptions {
return RetryOptions{
MaxRetries: 3,
InitialDelay: 1 * time.Second,
MaxDelay: 30 * time.Second,
Multiplier: 2.0,
RetryableHTTPStatusCodes: []int{
http.StatusRequestTimeout,
http.StatusTooManyRequests,
http.StatusInternalServerError,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout,
},
}
}
// Option represents a configuration option for the client
type Option func(*Client)
// WithHTTPClient sets a custom HTTP client
func WithHTTPClient(client *http.Client) Option {
return func(c *Client) {
c.HTTPClient = client
}
}
// WithAuthProvider sets the authentication provider
func WithAuthProvider(auth AuthProvider) Option {
return func(c *Client) {
c.AuthProvider = auth
}
}
// WithRetryOptions sets retry configuration
func WithRetryOptions(opts RetryOptions) Option {
return func(c *Client) {
c.RetryOpts = opts
}
}
// WithLogger sets a logger for debugging
func WithLogger(logger Logger) Option {
return func(c *Client) {
c.Logger = logger
}
}
// NewClient creates a new EdgeXR SDK client
func NewClient(baseURL string, options ...Option) *Client {
client := &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{Timeout: 30 * time.Second},
AuthProvider: NewNoAuthProvider(),
RetryOpts: DefaultRetryOptions(),
}
for _, opt := range options {
opt(client)
}
return client
}
// NewClientWithCredentials creates a new EdgeXR SDK client with username/password authentication
// This matches the existing client pattern from client/client.go
func NewClientWithCredentials(baseURL, username, password string, options ...Option) *Client {
client := &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{Timeout: 30 * time.Second},
AuthProvider: NewUsernamePasswordProvider(baseURL, username, password, nil),
RetryOpts: DefaultRetryOptions(),
}
for _, opt := range options {
opt(client)
}
return client
}
// logf logs a message if a logger is configured
func (c *Client) logf(format string, v ...interface{}) {
if c.Logger != nil {
c.Logger.Printf(format, v...)
}
}

View file

@ -1,283 +0,0 @@
// ABOUTME: Cloudlet management APIs for EdgeXR Master Controller
// ABOUTME: Provides typed methods for creating, querying, and managing edge cloudlets
package v2
import (
"context"
"encoding/json"
"fmt"
"net/http"
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
)
// CreateCloudlet creates a new cloudlet in the specified region
// Maps to POST /auth/ctrl/CreateCloudlet
func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/CreateCloudlet"
resp, err := transport.Call(ctx, "POST", url, input)
if err != nil {
return fmt.Errorf("CreateCloudlet failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateCloudlet")
}
c.logf("CreateCloudlet: %s/%s created successfully",
input.Cloudlet.Key.Organization, input.Cloudlet.Key.Name)
return nil
}
// ShowCloudlet retrieves a single cloudlet by key and region
// Maps to POST /auth/ctrl/ShowCloudlet
func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) (Cloudlet, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet"
filter := CloudletFilter{
Cloudlet: Cloudlet{Key: cloudletKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
}
if resp.StatusCode >= 400 {
return Cloudlet{}, c.handleErrorResponse(resp, "ShowCloudlet")
}
// Parse streaming JSON response
var cloudlets []Cloudlet
if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil {
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed to parse response: %w", err)
}
if len(cloudlets) == 0 {
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
}
return cloudlets[0], nil
}
// ShowCloudlets retrieves all cloudlets matching the filter criteria
// Maps to POST /auth/ctrl/ShowCloudlet
func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, region string) ([]Cloudlet, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet"
filter := CloudletFilter{
Cloudlet: Cloudlet{Key: cloudletKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return nil, fmt.Errorf("ShowCloudlets failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowCloudlets")
}
var cloudlets []Cloudlet
if resp.StatusCode == http.StatusNotFound {
return cloudlets, nil // Return empty slice for not found
}
if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil {
return nil, fmt.Errorf("ShowCloudlets failed to parse response: %w", err)
}
c.logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets))
return cloudlets, nil
}
// DeleteCloudlet removes a cloudlet from the specified region
// Maps to POST /auth/ctrl/DeleteCloudlet
func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) error {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteCloudlet"
filter := CloudletFilter{
Cloudlet: Cloudlet{Key: cloudletKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return fmt.Errorf("DeleteCloudlet failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return c.handleErrorResponse(resp, "DeleteCloudlet")
}
c.logf("DeleteCloudlet: %s/%s deleted successfully",
cloudletKey.Organization, cloudletKey.Name)
return nil
}
// GetCloudletManifest retrieves the deployment manifest for a cloudlet
// Maps to POST /auth/ctrl/GetCloudletManifest
func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletManifest, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletManifest"
filter := CloudletFilter{
Cloudlet: Cloudlet{Key: cloudletKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return nil, fmt.Errorf("GetCloudletManifest failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w",
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
}
if resp.StatusCode >= 400 {
return nil, c.handleErrorResponse(resp, "GetCloudletManifest")
}
// Parse the response as CloudletManifest
var manifest CloudletManifest
if err := c.parseDirectJSONResponse(resp, &manifest); err != nil {
return nil, fmt.Errorf("GetCloudletManifest failed to parse response: %w", err)
}
c.logf("GetCloudletManifest: retrieved manifest for %s/%s",
cloudletKey.Organization, cloudletKey.Name)
return &manifest, nil
}
// GetCloudletResourceUsage retrieves resource usage information for a cloudlet
// Maps to POST /auth/ctrl/GetCloudletResourceUsage
func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletResourceUsage, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletResourceUsage"
filter := CloudletFilter{
Cloudlet: Cloudlet{Key: cloudletKey},
Region: region,
}
resp, err := transport.Call(ctx, "POST", url, filter)
if err != nil {
return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w",
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
}
if resp.StatusCode >= 400 {
return nil, c.handleErrorResponse(resp, "GetCloudletResourceUsage")
}
// Parse the response as CloudletResourceUsage
var usage CloudletResourceUsage
if err := c.parseDirectJSONResponse(resp, &usage); err != nil {
return nil, fmt.Errorf("GetCloudletResourceUsage failed to parse response: %w", err)
}
c.logf("GetCloudletResourceUsage: retrieved usage for %s/%s",
cloudletKey.Organization, cloudletKey.Name)
return &usage, nil
}
// parseStreamingCloudletResponse parses the EdgeXR streaming JSON response format for cloudlets
func (c *Client) parseStreamingCloudletResponse(resp *http.Response, result interface{}) error {
var responses []Response[Cloudlet]
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
var response Response[Cloudlet]
if err := json.Unmarshal(line, &response); err != nil {
return err
}
responses = append(responses, response)
return nil
})
if parseErr != nil {
return parseErr
}
// Extract data from responses
var cloudlets []Cloudlet
var messages []string
for _, response := range responses {
if response.HasData() {
cloudlets = append(cloudlets, response.Data)
}
if response.IsMessage() {
messages = append(messages, response.Data.GetMessage())
}
}
// If we have error messages, return them
if len(messages) > 0 {
return &APIError{
StatusCode: resp.StatusCode,
Messages: messages,
}
}
// Set result based on type
switch v := result.(type) {
case *[]Cloudlet:
*v = cloudlets
default:
return fmt.Errorf("unsupported result type: %T", result)
}
return nil
}
// parseDirectJSONResponse parses a direct JSON response (not streaming)
func (c *Client) parseDirectJSONResponse(resp *http.Response, result interface{}) error {
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(result); err != nil {
return fmt.Errorf("failed to decode JSON response: %w", err)
}
return nil
}

View file

@ -1,408 +0,0 @@
// ABOUTME: Unit tests for Cloudlet management APIs using httptest mock server
// ABOUTME: Tests create, show, list, delete, manifest, and resource usage operations
package v2
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateCloudlet(t *testing.T) {
tests := []struct {
name string
input *NewCloudletInput
mockStatusCode int
mockResponse string
expectError bool
}{
{
name: "successful creation",
input: &NewCloudletInput{
Region: "us-west",
Cloudlet: Cloudlet{
Key: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
Location: Location{
Latitude: 37.7749,
Longitude: -122.4194,
},
IpSupport: "IpSupportDynamic",
NumDynamicIps: 10,
},
},
mockStatusCode: 200,
mockResponse: `{"message": "success"}`,
expectError: false,
},
{
name: "validation error",
input: &NewCloudletInput{
Region: "us-west",
Cloudlet: Cloudlet{
Key: CloudletKey{
Organization: "",
Name: "testcloudlet",
},
},
},
mockStatusCode: 400,
mockResponse: `{"message": "organization is required"}`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/CreateCloudlet", r.URL.Path)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
WithAuthProvider(NewStaticTokenProvider("test-token")),
)
// Execute test
ctx := context.Background()
err := client.CreateCloudlet(ctx, tt.input)
// Verify results
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestShowCloudlet(t *testing.T) {
tests := []struct {
name string
cloudletKey CloudletKey
region string
mockStatusCode int
mockResponse string
expectError bool
expectNotFound bool
}{
{
name: "successful show",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
region: "us-west",
mockStatusCode: 200,
mockResponse: `{"data": {"key": {"organization": "cloudletorg", "name": "testcloudlet"}, "state": "Ready", "location": {"latitude": 37.7749, "longitude": -122.4194}}}
`,
expectError: false,
expectNotFound: false,
},
{
name: "cloudlet not found",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "nonexistent",
},
region: "us-west",
mockStatusCode: 404,
mockResponse: "",
expectError: true,
expectNotFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" {
_, _ = w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
// Create client
client := NewClient(server.URL,
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
)
// Execute test
ctx := context.Background()
cloudlet, err := client.ShowCloudlet(ctx, tt.cloudletKey, tt.region)
// Verify results
if tt.expectError {
assert.Error(t, err)
if tt.expectNotFound {
assert.Contains(t, err.Error(), "resource not found")
}
} else {
require.NoError(t, err)
assert.Equal(t, tt.cloudletKey.Organization, cloudlet.Key.Organization)
assert.Equal(t, tt.cloudletKey.Name, cloudlet.Key.Name)
assert.Equal(t, "Ready", cloudlet.State)
}
})
}
}
func TestShowCloudlets(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path)
// Verify request body
var filter CloudletFilter
err := json.NewDecoder(r.Body).Decode(&filter)
require.NoError(t, err)
assert.Equal(t, "cloudletorg", filter.Cloudlet.Key.Organization)
assert.Equal(t, "us-west", filter.Region)
// Return multiple cloudlets
response := `{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet1"}, "state": "Ready"}}
{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}}
`
w.WriteHeader(200)
_, _ = w.Write([]byte(response))
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
cloudlets, err := client.ShowCloudlets(ctx, CloudletKey{Organization: "cloudletorg"}, "us-west")
require.NoError(t, err)
assert.Len(t, cloudlets, 2)
assert.Equal(t, "cloudlet1", cloudlets[0].Key.Name)
assert.Equal(t, "Ready", cloudlets[0].State)
assert.Equal(t, "cloudlet2", cloudlets[1].Key.Name)
assert.Equal(t, "Creating", cloudlets[1].State)
}
func TestDeleteCloudlet(t *testing.T) {
tests := []struct {
name string
cloudletKey CloudletKey
region string
mockStatusCode int
expectError bool
}{
{
name: "successful deletion",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
region: "us-west",
mockStatusCode: 200,
expectError: false,
},
{
name: "already deleted (404 ok)",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
region: "us-west",
mockStatusCode: 404,
expectError: false,
},
{
name: "server error",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
region: "us-west",
mockStatusCode: 500,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/DeleteCloudlet", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
err := client.DeleteCloudlet(ctx, tt.cloudletKey, tt.region)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestGetCloudletManifest(t *testing.T) {
tests := []struct {
name string
cloudletKey CloudletKey
region string
mockStatusCode int
mockResponse string
expectError bool
expectNotFound bool
}{
{
name: "successful manifest retrieval",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
region: "us-west",
mockStatusCode: 200,
mockResponse: `{"manifest": "apiVersion: v1\nkind: Deployment\nmetadata:\n name: test", "last_modified": "2024-01-01T00:00:00Z"}`,
expectError: false,
expectNotFound: false,
},
{
name: "manifest not found",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "nonexistent",
},
region: "us-west",
mockStatusCode: 404,
mockResponse: "",
expectError: true,
expectNotFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletManifest", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" {
_, _ = w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
manifest, err := client.GetCloudletManifest(ctx, tt.cloudletKey, tt.region)
if tt.expectError {
assert.Error(t, err)
if tt.expectNotFound {
assert.Contains(t, err.Error(), "resource not found")
}
} else {
require.NoError(t, err)
assert.NotNil(t, manifest)
assert.Contains(t, manifest.Manifest, "apiVersion: v1")
}
})
}
}
func TestGetCloudletResourceUsage(t *testing.T) {
tests := []struct {
name string
cloudletKey CloudletKey
region string
mockStatusCode int
mockResponse string
expectError bool
expectNotFound bool
}{
{
name: "successful usage retrieval",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "testcloudlet",
},
region: "us-west",
mockStatusCode: 200,
mockResponse: `{"cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}, "region": "us-west", "usage": {"cpu": "50%", "memory": "30%", "disk": "20%"}}`,
expectError: false,
expectNotFound: false,
},
{
name: "usage not found",
cloudletKey: CloudletKey{
Organization: "cloudletorg",
Name: "nonexistent",
},
region: "us-west",
mockStatusCode: 404,
mockResponse: "",
expectError: true,
expectNotFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletResourceUsage", r.URL.Path)
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" {
_, _ = w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
usage, err := client.GetCloudletResourceUsage(ctx, tt.cloudletKey, tt.region)
if tt.expectError {
assert.Error(t, err)
if tt.expectNotFound {
assert.Contains(t, err.Error(), "resource not found")
}
} else {
require.NoError(t, err)
assert.NotNil(t, usage)
assert.Equal(t, "cloudletorg", usage.CloudletKey.Organization)
assert.Equal(t, "testcloudlet", usage.CloudletKey.Name)
assert.Equal(t, "us-west", usage.Region)
assert.Contains(t, usage.Usage, "cpu")
}
})
}
}

View file

@ -1,421 +0,0 @@
// ABOUTME: Core type definitions for EdgeXR Master Controller SDK
// ABOUTME: These types are based on the swagger API specification and existing client patterns
package v2
import (
"encoding/json"
"fmt"
"time"
)
// App field constants for partial updates (based on EdgeXR API specification)
const (
AppFieldKey = "2"
AppFieldKeyOrganization = "2.1"
AppFieldKeyName = "2.2"
AppFieldKeyVersion = "2.3"
AppFieldImagePath = "4"
AppFieldImageType = "5"
AppFieldAccessPorts = "7"
AppFieldDefaultFlavor = "9"
AppFieldDefaultFlavorName = "9.1"
AppFieldAuthPublicKey = "12"
AppFieldCommand = "13"
AppFieldAnnotations = "14"
AppFieldDeployment = "15"
AppFieldDeploymentManifest = "16"
AppFieldDeploymentGenerator = "17"
AppFieldAndroidPackageName = "18"
AppFieldDelOpt = "20"
AppFieldConfigs = "21"
AppFieldConfigsKind = "21.1"
AppFieldConfigsConfig = "21.2"
AppFieldScaleWithCluster = "22"
AppFieldInternalPorts = "23"
AppFieldRevision = "24"
AppFieldOfficialFqdn = "25"
AppFieldMd5Sum = "26"
AppFieldAutoProvPolicy = "28"
AppFieldAccessType = "29"
AppFieldDeletePrepare = "31"
AppFieldAutoProvPolicies = "32"
AppFieldTemplateDelimiter = "33"
AppFieldSkipHcPorts = "34"
AppFieldCreatedAt = "35"
AppFieldCreatedAtSeconds = "35.1"
AppFieldCreatedAtNanos = "35.2"
AppFieldUpdatedAt = "36"
AppFieldUpdatedAtSeconds = "36.1"
AppFieldUpdatedAtNanos = "36.2"
AppFieldTrusted = "37"
AppFieldRequiredOutboundConnections = "38"
AppFieldAllowServerless = "39"
AppFieldServerlessConfig = "40"
AppFieldVmAppOsType = "41"
AppFieldAlertPolicies = "42"
AppFieldQosSessionProfile = "43"
AppFieldQosSessionDuration = "44"
)
// AppInstance field constants for partial updates (based on EdgeXR API specification)
const (
AppInstFieldKey = "2"
AppInstFieldKeyAppKey = "2.1"
AppInstFieldKeyAppKeyOrganization = "2.1.1"
AppInstFieldKeyAppKeyName = "2.1.2"
AppInstFieldKeyAppKeyVersion = "2.1.3"
AppInstFieldKeyClusterInstKey = "2.4"
AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1"
AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1"
AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2"
AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1"
AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2"
AppInstFieldKeyClusterInstKeyCloudletKeyFederatedOrganization = "2.4.2.3"
AppInstFieldKeyClusterInstKeyOrganization = "2.4.3"
AppInstFieldCloudletLoc = "3"
AppInstFieldCloudletLocLatitude = "3.1"
AppInstFieldCloudletLocLongitude = "3.2"
AppInstFieldCloudletLocHorizontalAccuracy = "3.3"
AppInstFieldCloudletLocVerticalAccuracy = "3.4"
AppInstFieldCloudletLocAltitude = "3.5"
AppInstFieldCloudletLocCourse = "3.6"
AppInstFieldCloudletLocSpeed = "3.7"
AppInstFieldCloudletLocTimestamp = "3.8"
AppInstFieldCloudletLocTimestampSeconds = "3.8.1"
AppInstFieldCloudletLocTimestampNanos = "3.8.2"
AppInstFieldUri = "4"
AppInstFieldLiveness = "6"
AppInstFieldMappedPorts = "9"
AppInstFieldMappedPortsProto = "9.1"
AppInstFieldMappedPortsInternalPort = "9.2"
AppInstFieldMappedPortsPublicPort = "9.3"
AppInstFieldMappedPortsFqdnPrefix = "9.5"
AppInstFieldMappedPortsEndPort = "9.6"
AppInstFieldMappedPortsTls = "9.7"
AppInstFieldMappedPortsNginx = "9.8"
AppInstFieldMappedPortsMaxPktSize = "9.9"
AppInstFieldFlavor = "12"
AppInstFieldFlavorName = "12.1"
AppInstFieldState = "14"
AppInstFieldErrors = "15"
AppInstFieldCrmOverride = "16"
AppInstFieldRuntimeInfo = "17"
AppInstFieldRuntimeInfoContainerIds = "17.1"
AppInstFieldCreatedAt = "21"
AppInstFieldCreatedAtSeconds = "21.1"
AppInstFieldCreatedAtNanos = "21.2"
AppInstFieldAutoClusterIpAccess = "22"
AppInstFieldRevision = "24"
AppInstFieldForceUpdate = "25"
AppInstFieldUpdateMultiple = "26"
AppInstFieldConfigs = "27"
AppInstFieldConfigsKind = "27.1"
AppInstFieldConfigsConfig = "27.2"
AppInstFieldHealthCheck = "29"
AppInstFieldPowerState = "31"
AppInstFieldExternalVolumeSize = "32"
AppInstFieldAvailabilityZone = "33"
AppInstFieldVmFlavor = "34"
AppInstFieldOptRes = "35"
AppInstFieldUpdatedAt = "36"
AppInstFieldUpdatedAtSeconds = "36.1"
AppInstFieldUpdatedAtNanos = "36.2"
AppInstFieldRealClusterName = "37"
AppInstFieldInternalPortToLbIp = "38"
AppInstFieldInternalPortToLbIpKey = "38.1"
AppInstFieldInternalPortToLbIpValue = "38.2"
AppInstFieldDedicatedIp = "39"
AppInstFieldUniqueId = "40"
AppInstFieldDnsLabel = "41"
)
// Message interface for types that can provide error messages
type Message interface {
GetMessage() string
}
// Base message type for API responses
type msg struct {
Message string `json:"message,omitempty"`
}
func (m msg) GetMessage() string {
return m.Message
}
// AppKey uniquely identifies an application
type AppKey struct {
Organization string `json:"organization"`
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
}
// CloudletKey uniquely identifies a cloudlet
type CloudletKey struct {
Organization string `json:"organization"`
Name string `json:"name"`
}
// AppInstanceKey uniquely identifies an application instance
type AppInstanceKey struct {
Organization string `json:"organization"`
Name string `json:"name"`
CloudletKey CloudletKey `json:"cloudlet_key"`
}
// Flavor defines resource allocation for instances
type Flavor struct {
Name string `json:"name"`
}
// SecurityRule defines network access rules
type SecurityRule struct {
PortRangeMax int `json:"port_range_max"`
PortRangeMin int `json:"port_range_min"`
Protocol string `json:"protocol"`
RemoteCIDR string `json:"remote_cidr"`
}
// App represents an application definition
type App struct {
msg `json:",inline"`
Key AppKey `json:"key"`
Deployment string `json:"deployment,omitempty"`
ImageType string `json:"image_type,omitempty"`
ImagePath string `json:"image_path,omitempty"`
AccessPorts string `json:"access_ports,omitempty"`
AllowServerless bool `json:"allow_serverless,omitempty"`
DefaultFlavor Flavor `json:"defaultFlavor,omitempty"`
ServerlessConfig interface{} `json:"serverless_config,omitempty"`
DeploymentGenerator string `json:"deployment_generator,omitempty"`
DeploymentManifest string `json:"deployment_manifest,omitempty"`
RequiredOutboundConnections []SecurityRule `json:"required_outbound_connections"`
GlobalID string `json:"global_id,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
Fields []string `json:"fields,omitempty"`
}
// AppInstance represents a deployed application instance
type AppInstance struct {
msg `json:",inline"`
Key AppInstanceKey `json:"key"`
AppKey AppKey `json:"app_key,omitempty"`
CloudletLoc CloudletLoc `json:"cloudlet_loc,omitempty"`
Flavor Flavor `json:"flavor,omitempty"`
State string `json:"state,omitempty"`
IngressURL string `json:"ingress_url,omitempty"`
UniqueID string `json:"unique_id,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
PowerState string `json:"power_state,omitempty"`
Fields []string `json:"fields,omitempty"`
}
// Cloudlet represents edge infrastructure
type Cloudlet struct {
msg `json:",inline"`
Key CloudletKey `json:"key"`
Location Location `json:"location"`
IpSupport string `json:"ip_support,omitempty"`
NumDynamicIps int32 `json:"num_dynamic_ips,omitempty"`
State string `json:"state,omitempty"`
Flavor Flavor `json:"flavor,omitempty"`
PhysicalName string `json:"physical_name,omitempty"`
Region string `json:"region,omitempty"`
NotifySrvAddr string `json:"notify_srv_addr,omitempty"`
}
// Location represents geographical coordinates
type Location struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
// CloudletLoc represents geographical coordinates for cloudlets
type CloudletLoc struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
// Input types for API operations
// NewAppInput represents input for creating an application
type NewAppInput struct {
Region string `json:"region"`
App App `json:"app"`
}
// NewAppInstanceInput represents input for creating an app instance
type NewAppInstanceInput struct {
Region string `json:"region"`
AppInst AppInstance `json:"appinst"`
}
// NewCloudletInput represents input for creating a cloudlet
type NewCloudletInput struct {
Region string `json:"region"`
Cloudlet Cloudlet `json:"cloudlet"`
}
// UpdateAppInput represents input for updating an application
type UpdateAppInput struct {
Region string `json:"region"`
App App `json:"app"`
}
// UpdateAppInstanceInput represents input for updating an app instance
type UpdateAppInstanceInput struct {
Region string `json:"region"`
AppInst AppInstance `json:"appinst"`
}
// DeleteAppInput represents input for deleting an application
type DeleteAppInput struct {
Region string `json:"region"`
App struct {
Key AppKey `json:"key"`
} `json:"app"`
}
// DeleteAppInstanceInput represents input for deleting an app instance
type DeleteAppInstanceInput struct {
Region string `json:"region"`
AppInst struct {
Key AppInstanceKey `json:"key"`
} `json:"appinst"`
}
// Response wrapper types
// Response wraps a single API response
type Response[T Message] struct {
ResultResponse `json:",inline"`
Data T `json:"data"`
}
func (res *Response[T]) HasData() bool {
return !res.IsMessage()
}
func (res *Response[T]) IsMessage() bool {
return res.Data.GetMessage() != ""
}
// ResultResponse represents an API result with error code
type ResultResponse struct {
Result struct {
Message string `json:"message"`
Code int `json:"code"`
} `json:"result"`
}
func (r *ResultResponse) IsError() bool {
return r.Result.Code >= 400
}
func (r *ResultResponse) GetMessage() string {
return r.Result.Message
}
func (r *ResultResponse) GetCode() int {
return r.Result.Code
}
// Responses wraps multiple API responses with metadata
type Responses[T Message] struct {
Responses []Response[T] `json:"responses,omitempty"`
StatusCode int `json:"-"`
Errors []error `json:"-"`
}
func (r *Responses[T]) GetData() []T {
var data []T
for _, v := range r.Responses {
if v.HasData() {
data = append(data, v.Data)
}
}
return data
}
func (r *Responses[T]) GetMessages() []string {
var messages []string
for _, v := range r.Responses {
if v.IsMessage() {
messages = append(messages, v.Data.GetMessage())
}
if v.Result.Message != "" {
messages = append(messages, v.Result.Message)
}
}
return messages
}
func (r *Responses[T]) IsSuccessful() bool {
return len(r.Errors) == 0 && (r.StatusCode >= 200 && r.StatusCode < 400)
}
func (r *Responses[T]) Error() error {
if r.IsSuccessful() {
return nil
}
return &APIError{
StatusCode: r.StatusCode,
Messages: r.GetMessages(),
}
}
// APIError represents an API error with details
type APIError struct {
StatusCode int `json:"status_code"`
Code string `json:"code,omitempty"`
Messages []string `json:"messages,omitempty"`
Body []byte `json:"-"`
}
func (e *APIError) Error() string {
jsonErr, err := json.Marshal(e)
if err != nil {
return fmt.Sprintf("API error: %v", err)
}
return fmt.Sprintf("API error: %s", jsonErr)
}
// Filter types for querying
// AppFilter represents filters for app queries
type AppFilter struct {
App App `json:"app"`
Region string `json:"region"`
}
// AppInstanceFilter represents filters for app instance queries
type AppInstanceFilter struct {
AppInstance AppInstance `json:"appinst"`
Region string `json:"region"`
}
// CloudletFilter represents filters for cloudlet queries
type CloudletFilter struct {
Cloudlet Cloudlet `json:"cloudlet"`
Region string `json:"region"`
}
// CloudletManifest represents cloudlet deployment manifest
type CloudletManifest struct {
Manifest string `json:"manifest"`
LastModified time.Time `json:"last_modified,omitempty"`
}
// CloudletResourceUsage represents cloudlet resource utilization
type CloudletResourceUsage struct {
CloudletKey CloudletKey `json:"cloudlet_key"`
Region string `json:"region"`
Usage map[string]interface{} `json:"usage"`
}
type ErrorMessage struct {
Message string
}

Some files were not shown because too many files have changed in this diff Show more