Compare commits

...

40 commits
v0.0.5 ... main

Author SHA1 Message Date
02856be541
fix: Fixed error handling
All checks were successful
test / test (push) Successful in 44s
ci / goreleaser (push) Successful in 1m39s
2025-11-17 15:43:08 +01:00
e38d7e84d5
parseStreamingResponse is now unified for all objects under both versions
All checks were successful
test / test (push) Successful in 45s
2025-11-17 14:40:47 +01:00
2909e0d1b4
feat(api): add nicer error message to format issues indicating permission denied
All checks were successful
test / test (push) Successful in 42s
2025-11-14 12:11:24 +01:00
ece2955a2a
feat(api): Added AppKey to ShowAppInstances
All checks were successful
test / test (push) Successful in 56s
ci / goreleaser (push) Successful in 54s
2025-11-13 16:59:38 +01:00
a51e2ae454
feat(api): Added AppKey property to ShowAppInstances
All checks were successful
test / test (push) Successful in 55s
ci / goreleaser (push) Successful in 1m9s
2025-11-13 16:15:15 +01:00
ece3dddfe6 feat(edge): Added ubuntu buildkit edge v1 (running) and v2 (not running) example
All checks were successful
test / test (push) Successful in 1m10s
2025-10-27 16:32:57 +01:00
9772a072e8 chore(linting): Fixed all linter errors
All checks were successful
test / test (push) Successful in 46s
2025-10-22 12:47:15 +02:00
f3cbfa3723 fix(deploy): Fixed glitch when updating an app inst with an invalid manifest
All checks were successful
test / test (push) Successful in 16s
2025-10-22 10:31:03 +02:00
26ba07200e test(orca-forgjo-runner): added v2 example to deploy forgejo runner in orca
All checks were successful
test / test (push) Successful in 16s
2025-10-21 13:44:33 +02:00
716c8e79e4 fix(version): update imports and go.mod to allow v2
All checks were successful
test / test (push) Successful in 51s
ci / goreleaser (push) Successful in 24s
2025-10-21 11:40:35 +02:00
9cb9f97a1f feat(signing): added multi arch build
All checks were successful
ci / goreleaser (push) Successful in 34s
2025-10-20 16:49:41 +02:00
65e0185064 feat(signing): added public key
All checks were successful
ci / goreleaser (push) Successful in 44s
2025-10-20 16:47:00 +02:00
318af7baff feat(signing): added goreleaser signing
All checks were successful
ci / goreleaser (push) Successful in 25s
2025-10-20 15:59:05 +02:00
a70e107a3f feat(signing): added goreleaser signing 2025-10-20 15:55:58 +02:00
df697c0ff6 fix(sdk): correct delete payload structure for v2 API and add delete command
The v2 API requires a different JSON payload structure than what was being sent.
Both DeleteApp and DeleteAppInstance needed to wrap their parameters properly.

SDK Changes:
- Update DeleteAppInput to use {region, app: {key}} structure
- Update DeleteAppInstanceInput to use {region, appinst: {key}} structure
- Fix DeleteApp method to populate new payload structure
- Fix DeleteAppInstance method to populate new payload structure

CLI Changes:
- Add delete command with -f flag for config file specification
- Support --dry-run to preview deletions
- Support --auto-approve to skip confirmation
- Implement v1 and v2 API support following same pattern as apply
- Add deletion planner to discover resources matching config
- Add resource manager to execute deletions (instances first, then app)

Test Changes:
- Update example_test.go to use EdgeConnectConfig_v1.yaml
- All tests passing including comprehensive delete test coverage

Verified working with manual API testing against live endpoint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:15:23 +02:00
f921169351 feat(examples): added edge connect v1 and v2 examples 2025-10-20 14:29:45 +02:00
98a8c4db4a feat(apply): add v1 API support to apply command
Refactor apply command to support both v1 and v2 APIs:
- Split internal/apply into v1 and v2 subdirectories
- v1: Uses sdk/edgeconnect (from revision/v1 branch)
- v2: Uses sdk/edgeconnect/v2
- Update cmd/apply.go to route to appropriate version based on api_version config
- Both versions now fully functional with their respective API endpoints

Changes:
- Created internal/apply/v1/ with v1 SDK implementation
- Created internal/apply/v2/ with v2 SDK implementation
- Updated cmd/apply.go with runApplyV1() and runApplyV2() functions
- Removed validation error that rejected v1
- Apply command now respects --api-version flag and config setting

Testing:
- V1 with edge.platform:  Generates deployment plan correctly
- V2 with orca.platform:  Works as before

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 13:57:57 +02:00
59ba5ffb02 fix(apply): add validation to reject v1 API version
The apply command requires v2 API features and cannot work with v1.
Add early validation to provide a clear error message when users try
to use apply with --api-version v1, instead of failing with a cryptic
403 Forbidden error.

Error message explains that apply only supports v2 and guides users
to use --api-version v2 or remove the api_version setting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 13:49:09 +02:00
2a8e99eb63 feat(config): add API version selector for v1 and v2
Add configurable API version selection with three methods:
- Config file: api_version: "v1" or "v2" in .edge-connect.yaml
- CLI flag: --api-version v1/v2
- Environment variable: EDGE_CONNECT_API_VERSION=v1/v2

Changes:
- Update root.go to add api_version config and env var support
- Update app.go and instance.go to support both v1 and v2 clients
- Add example config file with api_version documentation
- Default to v2 for backward compatibility
- Apply command always uses v2 (advanced feature)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 13:41:50 +02:00
3486b2228d refactor(sdk): restructure to follow Go module versioning conventions
Reorganize SDK to support both v1 and v2 APIs following Go conventions:
- sdk/edgeconnect/ now contains v1 SDK (from revision/v1 branch)
- sdk/edgeconnect/v2/ contains v2 SDK with package v2
- Update all CLI and internal imports to use v2 path
- Update SDK examples and documentation for v2 import path

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 13:34:22 +02:00
1413836b68 feat(swagger_v2): added support for the orca staging environment 2025-10-20 13:12:06 +02:00
0f71239db6 doc(api): rename current swagger to _v2, add old swagger as _v1 2025-10-20 10:05:24 +02:00
dbf7ccb0d6 chore(http-timeout): removed timeout functionality when calling the API as it was not needed and malfunctional
All checks were successful
ci / goreleaser (push) Successful in 59s
2025-10-17 12:01:47 +02:00
5f54082813 doc(create-appinstance): added documentation of the correct parsing of an errorneous app instance creation response 2025-10-16 17:42:29 +02:00
0b31409b26 feat(parser): add result parser of createappinstance and added a configurable timeout for that function 2025-10-16 11:12:57 +02:00
8f6fd94442 feat(edge-connect): Added Forgejo Runner Deployment in Edge Connect Example 2025-10-15 16:00:38 +02:00
4ded2e193e feat(ec-api): new swagger from EC (Alex) with changes update app and appinstances. They call it 2.0 which already was delivered.
we discussed in Teams:

Malashevich, Alex (ext) Freitag 10.10.25 17:19
Updated spec is available. It's relevant for Orca cluster you'll be added next week I hope
Swagger UI https://swagger.edge.platform.mg3.mdb.osc.live/#/

Stephan Lo, , Montag 13.10.25 09:37
hey alex ... this is great news! just a quick question: We still see version '2.0' - does this mean that there were no changes?

Malashevich, Alex (ext) Montag 13.10.25 09:49
yes, it's just relevant update of current state of things for external teams to integrate with us (FE, Developer Framework, AI, etc). So the spec you've seen before is our internal so to say
2025-10-13 10:10:16 +02:00
c7b1284606 fix(test): finish fixing organisation refactoring tests failures 2025-10-07 17:21:38 +02:00
921822239b fix(test): finish fixing organisation refactoring tests failures 2025-10-07 17:19:52 +02:00
f32479aaf8 fix(test): fixed compile errors 2025-10-07 17:09:36 +02:00
a72341356b fix(test): started fixing tests 2025-10-07 17:05:35 +02:00
bc524c3b0e refactor(yaml): Moved organisation to metadata 2025-10-07 16:30:57 +02:00
0f3cc90b01 ci: Added test workflow running on each push except tags 2025-10-07 16:10:02 +02:00
06f921963a refactor(yaml): moved AppVersion into metadata 2025-10-07 16:01:38 +02:00
cc8b9e791b fix(cli): Fixed tests after outputting plan diff 2025-10-07 15:40:27 +02:00
f635157d67 chore: Added flake 2025-10-07 14:37:54 +02:00
e092f352f8 feat(cli): Added hash compare between current and desired manifest state without using annotation. instead the current hash is calculated from the showapp() app.deploymentmanifest field 2025-10-06 17:08:33 +02:00
6de170f6cf feat(cli): Added output of diff when updating outboundConnections in the desired manifest 2025-10-06 16:45:53 +02:00
393977c7fc feat(cli): Added an auto approve flag for apply
All checks were successful
ci / goreleaser (push) Successful in 1m52s
2025-10-02 14:52:40 +02:00
e061883c32 fix(cli): Run tests before release
All checks were successful
ci / goreleaser (push) Successful in 1m19s
2025-10-02 13:50:28 +02:00
78 changed files with 11088 additions and 563 deletions

View file

@ -0,0 +1,14 @@
# 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"

1
.envrc.example Normal file
View file

@ -0,0 +1 @@
use flake

View file

@ -17,9 +17,18 @@ jobs:
uses: actions/setup-go@v6
with:
go-version: ">=1.25.1"
- name: Test code
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
uses: https://github.com/goreleaser/goreleaser-action@v6
env:
GITEA_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
with:
args: release --clean

23
.github/workflows/test.yaml vendored Normal file
View file

@ -0,0 +1,23 @@
name: test
on:
push:
branches:
- '*'
tags-ignore:
- '*'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ">=1.25.1"
- name: Test code
run: make test

6
.gitignore vendored
View file

@ -1,3 +1,9 @@
edge-connect
# Added by goreleaser init:
dist/
### direnv ###
.direnv
.envrc
edge-connect-client

View file

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

View file

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

868
api/swagger_v2.json Normal file
View file

@ -0,0 +1,868 @@
{
"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

@ -3,12 +3,15 @@ package cmd
import (
"context"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
"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"
)
@ -34,7 +37,7 @@ func validateBaseURL(baseURL string) error {
return fmt.Errorf("user and or password should not be set")
}
if !(url.Path == "" || url.Path == "/") {
if url.Path != "" && url.Path != "/" {
return fmt.Errorf("should not contain any path '%s'", url.Path)
}
@ -49,7 +52,15 @@ func validateBaseURL(baseURL string) error {
return nil
}
func newSDKClient() *edgeconnect.Client {
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")
@ -60,16 +71,53 @@ func newSDKClient() *edgeconnect.Client {
os.Exit(1)
}
if username != "" && password != "" {
return edgeconnect.NewClientWithCredentials(baseURL, username, password,
// 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,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
)
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{
@ -82,7 +130,11 @@ var createAppCmd = &cobra.Command{
Use: "create",
Short: "Create a new Edge Connect application",
Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient()
apiVersion := getAPIVersion()
var err error
if apiVersion == "v1" {
c := newSDKClientV1()
input := &edgeconnect.NewAppInput{
Region: region,
App: edgeconnect.App{
@ -93,8 +145,22 @@ var createAppCmd = &cobra.Command{
},
},
}
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)
}
err := c.CreateApp(context.Background(), input)
if err != nil {
fmt.Printf("Error creating app: %v\n", err)
os.Exit(1)
@ -107,19 +173,35 @@ var showAppCmd = &cobra.Command{
Use: "show",
Short: "Show details of an Edge Connect application",
Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient()
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)
}
},
}
@ -127,13 +209,15 @@ var listAppsCmd = &cobra.Command{
Use: "list",
Short: "List Edge Connect applications",
Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient()
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)
@ -143,6 +227,23 @@ var listAppsCmd = &cobra.Command{
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)
}
}
},
}
@ -150,14 +251,27 @@ var deleteAppCmd = &cobra.Command{
Use: "delete",
Short: "Delete an Edge Connect application",
Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient()
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)
}
err := c.DeleteApp(context.Background(), appKey, region)
if err != nil {
fmt.Printf("Error deleting app: %v\n", err)
os.Exit(1)
@ -177,12 +291,18 @@ func init() {
cmd.Flags().StringVarP(&appName, "name", "n", "", "application name")
cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
cmd.Flags().StringVarP(&region, "region", "r", "", "region (required)")
cmd.MarkFlagRequired("org")
cmd.MarkFlagRequired("region")
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} {
cmd.MarkFlagRequired("name")
if err := cmd.MarkFlagRequired("name"); err != nil {
panic(err)
}
}
}

View file

@ -10,14 +10,16 @@ import (
"path/filepath"
"strings"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
applyv1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/apply/v1"
applyv2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/apply/v2"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
"github.com/spf13/cobra"
)
var (
configFile string
dryRun bool
autoApprove bool
)
var applyCmd = &cobra.Command{
@ -29,18 +31,18 @@ the necessary changes to deploy your applications across multiple cloudlets.`,
Run: func(cmd *cobra.Command, args []string) {
if configFile == "" {
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n")
cmd.Usage()
_ = cmd.Usage()
os.Exit(1)
}
if err := runApply(configFile, dryRun); err != nil {
if err := runApply(configFile, dryRun, autoApprove); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func runApply(configPath string, isDryRun bool) error {
func runApply(configPath string, isDryRun bool, autoApprove bool) error {
// Step 1: Validate and resolve config file path
absPath, err := filepath.Abs(configPath)
if err != nil {
@ -66,16 +68,27 @@ func runApply(configPath string, isDryRun bool) error {
fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name)
// Step 3: Create EdgeConnect client
client := newSDKClient()
// Step 3: Determine API version and create appropriate client
apiVersion := getAPIVersion()
// Step 4: Create deployment planner
planner := apply.NewPlanner(client)
// Step 4-6: Execute deployment based on API version
if apiVersion == "v1" {
return runApplyV1(cfg, manifestContent, isDryRun, autoApprove)
}
return runApplyV2(cfg, manifestContent, isDryRun, autoApprove)
}
// Step 5: Generate deployment plan
func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error {
// Create v1 client
client := newSDKClientV1()
// Create deployment planner
planner := applyv1.NewPlanner(client)
// Generate deployment plan
fmt.Println("🔍 Analyzing current state and generating deployment plan...")
planOptions := apply.DefaultPlanOptions()
planOptions := applyv1.DefaultPlanOptions()
planOptions.DryRun = isDryRun
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
@ -83,7 +96,7 @@ func runApply(configPath string, isDryRun bool) error {
return fmt.Errorf("failed to generate deployment plan: %w", err)
}
// Step 6: Display plan summary
// Display plan summary
fmt.Println("\n📋 Deployment Plan:")
fmt.Println(strings.Repeat("=", 50))
fmt.Println(result.Plan.Summary)
@ -97,13 +110,13 @@ func runApply(configPath string, isDryRun bool) error {
}
}
// Step 7: If dry-run, stop here
// If dry-run, stop here
if isDryRun {
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
return nil
}
// Step 8: Confirm deployment (in non-dry-run mode)
// Confirm deployment
if result.Plan.TotalActions == 0 {
fmt.Println("\n✅ No changes needed. Resources are already in desired state.")
return nil
@ -112,21 +125,103 @@ func runApply(configPath string, isDryRun bool) error {
fmt.Printf("\nThis will perform %d actions. Estimated time: %v\n",
result.Plan.TotalActions, result.Plan.EstimatedDuration)
if !confirmDeployment() {
if !autoApprove && !confirmDeployment() {
fmt.Println("Deployment cancelled.")
return nil
}
// Step 9: Execute deployment
// Execute deployment
fmt.Println("\n🚀 Starting deployment...")
manager := apply.NewResourceManager(client, apply.WithLogger(log.Default()))
manager := applyv1.NewResourceManager(client, applyv1.WithLogger(log.Default()))
deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
if err != nil {
return fmt.Errorf("deployment failed: %w", err)
}
// Step 10: Display results
// 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 {
fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration)
if len(deployResult.CompletedActions) > 0 {
@ -148,14 +243,38 @@ func runApply(configPath string, isDryRun bool) error {
}
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
}
func confirmDeployment() bool {
fmt.Print("Do you want to proceed? (yes/no): ")
var response string
fmt.Scanln(&response)
_, _ = fmt.Scanln(&response)
switch response {
case "yes", "y", "YES", "Y":
@ -170,6 +289,9 @@ func init() {
applyCmd.Flags().StringVarP(&configFile, "file", "f", "", "configuration file path (required)")
applyCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without applying them")
applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan")
applyCmd.MarkFlagRequired("file")
if err := applyCmd.MarkFlagRequired("file"); err != nil {
panic(err)
}
}

296
cmd/delete.go Normal file
View file

@ -0,0 +1,296 @@
// 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

@ -5,7 +5,8 @@ import (
"fmt"
"os"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
"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"
)
@ -14,6 +15,7 @@ var (
cloudletOrg string
instanceName string
flavorName string
appId string
)
var appInstanceCmd = &cobra.Command{
@ -26,7 +28,11 @@ var createInstanceCmd = &cobra.Command{
Use: "create",
Short: "Create a new Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient()
apiVersion := getAPIVersion()
var err error
if apiVersion == "v1" {
c := newSDKClientV1()
input := &edgeconnect.NewAppInstanceInput{
Region: region,
AppInst: edgeconnect.AppInstance{
@ -48,8 +54,33 @@ var createInstanceCmd = &cobra.Command{
},
},
}
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)
}
err := c.CreateAppInstance(context.Background(), input)
if err != nil {
fmt.Printf("Error creating app instance: %v\n", err)
os.Exit(1)
@ -62,7 +93,10 @@ var showInstanceCmd = &cobra.Command{
Use: "show",
Short: "Show details of an Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient()
apiVersion := getAPIVersion()
if apiVersion == "v1" {
c := newSDKClientV1()
instanceKey := edgeconnect.AppInstanceKey{
Organization: organization,
Name: instanceName,
@ -71,13 +105,31 @@ var showInstanceCmd = &cobra.Command{
Name: cloudletName,
},
}
instance, err := c.ShowAppInstance(context.Background(), instanceKey, region)
appkey := edgeconnect.AppKey{Name: appId}
instance, err := c.ShowAppInstance(context.Background(), instanceKey, appkey, region)
if err != nil {
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)
}
},
}
@ -85,7 +137,10 @@ var listInstancesCmd = &cobra.Command{
Use: "list",
Short: "List Edge Connect application instances",
Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient()
apiVersion := getAPIVersion()
if apiVersion == "v1" {
c := newSDKClientV1()
instanceKey := edgeconnect.AppInstanceKey{
Organization: organization,
Name: instanceName,
@ -94,8 +149,8 @@ var listInstancesCmd = &cobra.Command{
Name: cloudletName,
},
}
instances, err := c.ShowAppInstances(context.Background(), instanceKey, region)
appKey := edgeconnect.AppKey{Name: appId}
instances, err := c.ShowAppInstances(context.Background(), instanceKey, appKey, region)
if err != nil {
fmt.Printf("Error listing app instances: %v\n", err)
os.Exit(1)
@ -104,6 +159,27 @@ var listInstancesCmd = &cobra.Command{
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)
}
}
},
}
@ -111,7 +187,11 @@ var deleteInstanceCmd = &cobra.Command{
Use: "delete",
Short: "Delete an Edge Connect application instance",
Run: func(cmd *cobra.Command, args []string) {
c := newSDKClient()
apiVersion := getAPIVersion()
var err error
if apiVersion == "v1" {
c := newSDKClientV1()
instanceKey := edgeconnect.AppInstanceKey{
Organization: organization,
Name: instanceName,
@ -120,8 +200,20 @@ var deleteInstanceCmd = &cobra.Command{
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)
}
err := c.DeleteAppInstance(context.Background(), instanceKey, region)
if err != nil {
fmt.Printf("Error deleting app instance: %v\n", err)
os.Exit(1)
@ -142,18 +234,33 @@ func init() {
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")
cmd.MarkFlagRequired("org")
cmd.MarkFlagRequired("name")
cmd.MarkFlagRequired("cloudlet")
cmd.MarkFlagRequired("cloudlet-org")
cmd.MarkFlagRequired("region")
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)")
createInstanceCmd.MarkFlagRequired("app")
createInstanceCmd.MarkFlagRequired("flavor")
if err := createInstanceCmd.MarkFlagRequired("app"); err != nil {
panic(err)
}
if err := createInstanceCmd.MarkFlagRequired("flavor"); err != nil {
panic(err)
}
}

View file

@ -13,6 +13,8 @@ var (
baseURL string
username string
password string
debug bool
apiVersion string
)
// rootCmd represents the base command when called without any subcommands
@ -39,18 +41,38 @@ func init() {
rootCmd.PersistentFlags().StringVar(&baseURL, "base-url", "", "base URL for the Edge Connect API")
rootCmd.PersistentFlags().StringVar(&username, "username", "", "username 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")
viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url"))
viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username"))
viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password"))
if err := viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")); err != nil {
panic(err)
}
if err := viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")); err != nil {
panic(err)
}
if err := viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")); err != nil {
panic(err)
}
if err := viper.BindPFlag("api_version", rootCmd.PersistentFlags().Lookup("api-version")); err != nil {
panic(err)
}
}
func initConfig() {
viper.AutomaticEnv()
viper.SetEnvPrefix("EDGE_CONNECT")
viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL")
viper.BindEnv("username", "EDGE_CONNECT_USERNAME")
viper.BindEnv("password", "EDGE_CONNECT_PASSWORD")
if err := viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL"); err != nil {
panic(err)
}
if err := viper.BindEnv("username", "EDGE_CONNECT_USERNAME"); err != nil {
panic(err)
}
if err := viper.BindEnv("password", "EDGE_CONNECT_PASSWORD"); err != nil {
panic(err)
}
if err := viper.BindEnv("api_version", "EDGE_CONNECT_API_VERSION"); err != nil {
panic(err)
}
if cfgFile != "" {
viper.SetConfigFile(cfgFile)

25
flake.lock generated Normal file
View file

@ -0,0 +1,25 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1759733170,
"narHash": "sha256-TXnlsVb5Z8HXZ6mZoeOAIwxmvGHp1g4Dw89eLvIwKVI=",
"rev": "8913c168d1c56dc49a7718685968f38752171c3b",
"revCount": 873256,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.873256%2Brev-8913c168d1c56dc49a7718685968f38752171c3b/0199bd36-8ae7-7817-b019-8688eb4f61ff/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/NixOS/nixpkgs/0.1"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

38
flake.nix Normal file
View file

@ -0,0 +1,38 @@
{
description = "A Nix-flake-based Go development environment";
inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1";
outputs = inputs:
let
goVersion = 25; # Change this to update the whole stack
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forEachSupportedSystem = f: inputs.nixpkgs.lib.genAttrs supportedSystems (system: f {
pkgs = import inputs.nixpkgs {
inherit system;
overlays = [ inputs.self.overlays.default ];
};
});
in
{
overlays.default = final: prev: {
go = final."go_1_${toString goVersion}";
};
devShells = forEachSupportedSystem ({ pkgs }: {
default = pkgs.mkShell {
packages = with pkgs; [
# go (version is specified by overlay)
go
# goimports, godoc, etc.
gotools
# https://github.com/golangci/golangci-lint
golangci-lint
];
};
});
};
}

2
go.mod
View file

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

View file

@ -1,14 +1,14 @@
// 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 apply
package v1
import (
"context"
"fmt"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
)
// ResourceManagerInterface defines the interface for resource management
@ -265,7 +265,7 @@ func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, acti
for _, instanceAction := range plan.InstanceActions {
if instanceAction.InstanceName == action.Target {
instanceKey := edgeconnect.AppInstanceKey{
Organization: instanceAction.Target.Organization,
Organization: plan.AppAction.Desired.Organization,
Name: instanceAction.InstanceName,
CloudletKey: edgeconnect.CloudletKey{
Organization: instanceAction.Target.CloudletOrg,

View file

@ -1,6 +1,6 @@
// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios
// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients
package apply
package v1
import (
"context"
@ -10,8 +10,8 @@ import (
"testing"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
"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"
@ -110,7 +110,6 @@ func createTestDeploymentPlan() *DeploymentPlan {
{
Type: ActionCreate,
Target: config.InfraTemplate{
Organization: "testorg",
Region: "US",
CloudletOrg: "cloudletorg",
CloudletName: "cloudlet1",
@ -138,15 +137,15 @@ func createTestManagerConfig(t *testing.T) *config.EdgeConnectConfig {
Kind: "edgeconnect-deployment",
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "testorg",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{
AppVersion: "1.0.0",
ManifestFile: manifestFile,
},
InfraTemplate: []config.InfraTemplate{
{
Organization: "testorg",
Region: "US",
CloudletOrg: "cloudletorg",
CloudletName: "cloudlet1",
@ -309,7 +308,6 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
{
Type: ActionCreate,
Target: config.InfraTemplate{
Organization: "testorg",
Region: "US",
CloudletOrg: "cloudletorg1",
CloudletName: "cloudlet1",
@ -321,7 +319,6 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
{
Type: ActionCreate,
Target: config.InfraTemplate{
Organization: "testorg",
Region: "EU",
CloudletOrg: "cloudletorg2",
CloudletName: "cloudlet2",

View file

@ -1,6 +1,6 @@
// 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 apply
package v1
import (
"context"
@ -11,8 +11,8 @@ import (
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
"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
@ -21,7 +21,7 @@ type EdgeConnectClientInterface interface {
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, region string) (edgeconnect.AppInstance, error)
ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error)
CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error
UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error
@ -134,8 +134,8 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E
// Build desired app state
desired := &AppState{
Name: config.Metadata.Name,
Version: config.Spec.GetAppVersion(),
Organization: config.Spec.InfraTemplate[0].Organization, // Use first infra template for org
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
}
@ -204,6 +204,7 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E
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")
@ -219,12 +220,12 @@ func (p *EdgeConnectPlanner) planInstanceActions(ctx context.Context, config *co
var warnings []string
for _, infra := range config.Spec.InfraTemplate {
instanceName := getInstanceName(config.Metadata.Name, config.Spec.GetAppVersion())
instanceName := getInstanceName(config.Metadata.Name, config.Metadata.AppVersion)
desired := &InstanceState{
Name: instanceName,
AppVersion: config.Spec.GetAppVersion(),
Organization: infra.Organization,
AppVersion: config.Metadata.AppVersion,
Organization: config.Metadata.Organization,
Region: infra.Region,
CloudletOrg: infra.CloudletOrg,
CloudletName: infra.CloudletName,
@ -304,6 +305,11 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
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
@ -317,12 +323,7 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
// Extract outbound connections from the app
current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections))
for i, conn := range app.RequiredOutboundConnections {
current.OutboundConnections[i] = SecurityRule{
Protocol: conn.Protocol,
PortRangeMin: conn.PortRangeMin,
PortRangeMax: conn.PortRangeMax,
RemoteCIDR: conn.RemoteCIDR,
}
current.OutboundConnections[i] = SecurityRule(conn)
}
return current, nil
@ -341,8 +342,11 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire
Name: desired.CloudletName,
},
}
appKey := edgeconnect.AppKey{
Name: desired.AppName,
}
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region)
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region)
if err != nil {
return nil, err
}
@ -384,17 +388,25 @@ func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]str
}
// Compare outbound connections
if !p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections) {
changes = append(changes, "Outbound connections changed")
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) bool {
makeMap := func(rules []SecurityRule) map[string]struct{} {
m := make(map[string]struct{}, len(rules))
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),
@ -402,7 +414,7 @@ func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []Secur
r.PortRangeMax,
r.RemoteCIDR,
)
m[key] = struct{}{}
m[key] = r
}
return m
}
@ -410,17 +422,21 @@ func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []Secur
currentMap := makeMap(current)
desiredMap := makeMap(desired)
if len(currentMap) != len(desiredMap) {
return false
}
for k := range currentMap {
if _, exists := desiredMap[k]; !exists {
return false
// 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))
}
}
return true
// 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
@ -452,7 +468,9 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string,
if err != nil {
return "", fmt.Errorf("failed to open manifest file: %w", err)
}
defer file.Close()
defer func() {
_ = file.Close()
}()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
@ -487,18 +505,20 @@ func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) ti
var duration time.Duration
// App operations
if plan.AppAction.Type == ActionCreate {
switch plan.AppAction.Type {
case ActionCreate:
duration += 30 * time.Second
} else if plan.AppAction.Type == ActionUpdate {
case ActionUpdate:
duration += 15 * time.Second
}
// Instance operations (can be done in parallel)
instanceDuration := time.Duration(0)
for _, action := range plan.InstanceActions {
if action.Type == ActionCreate {
switch action.Type {
case ActionCreate:
instanceDuration = max(instanceDuration, 2*time.Minute)
} else if action.Type == ActionUpdate {
case ActionUpdate:
instanceDuration = max(instanceDuration, 1*time.Minute)
}
}

View file

@ -1,6 +1,6 @@
// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios
// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios
package apply
package v1
import (
"context"
@ -9,8 +9,8 @@ import (
"testing"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
"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"
@ -29,7 +29,7 @@ func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.
return args.Get(0).(edgeconnect.App), args.Error(1)
}
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) {
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error) {
args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil {
return edgeconnect.AppInstance{}, args.Error(1)
@ -75,14 +75,6 @@ func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect
return args.Get(0).([]edgeconnect.App), args.Error(1)
}
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) {
args := m.Called(ctx, instanceKey, region)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]edgeconnect.AppInstance), args.Error(1)
}
func TestNewPlanner(t *testing.T) {
mockClient := &MockEdgeConnectClient{}
planner := NewPlanner(mockClient)
@ -113,15 +105,15 @@ func createTestConfig(t *testing.T) *config.EdgeConnectConfig {
Kind: "edgeconnect-deployment",
Metadata: config.Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "testorg",
},
Spec: config.Spec{
K8sApp: &config.K8sApp{
AppVersion: "1.0.0",
ManifestFile: manifestFile,
},
InfraTemplate: []config.InfraTemplate{
{
Organization: "testorg",
Region: "US",
CloudletOrg: "TestCloudletOrg",
CloudletName: "TestCloudlet",
@ -185,6 +177,7 @@ func TestPlanExistingDeploymentNoChanges(t *testing.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",
@ -192,6 +185,7 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) {
Version: "1.0.0",
},
Deployment: "kubernetes",
DeploymentManifest: manifestContent,
RequiredOutboundConnections: []edgeconnect.SecurityRule{
{
Protocol: "tcp",
@ -284,7 +278,6 @@ func TestPlanMultipleInfrastructures(t *testing.T) {
// Add a second infrastructure target
testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{
Organization: "testorg",
Region: "EU",
CloudletOrg: "EUCloudletOrg",
CloudletName: "EUCloudlet",

View file

@ -1,13 +1,13 @@
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
package apply
package v1
import (
"context"
"fmt"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
)
// DeploymentStrategy represents the type of deployment strategy

View file

@ -1,16 +1,17 @@
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
package apply
package v1
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
)
// RecreateStrategy implements the recreate deployment strategy
@ -355,6 +356,15 @@ func (r *RecreateStrategy) executeInstanceActionWithRetry(ctx context.Context, a
}
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)
}
@ -395,6 +405,15 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action
}
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)
}
@ -408,7 +427,7 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action
// deleteInstance deletes an instance (reuse existing logic from manager.go)
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
instanceKey := edgeconnect.AppInstanceKey{
Organization: action.Target.Organization,
Organization: action.Desired.Organization,
Name: action.InstanceName,
CloudletKey: edgeconnect.CloudletKey{
Organization: action.Target.CloudletOrg,
@ -430,7 +449,7 @@ func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAc
Region: action.Target.Region,
AppInst: edgeconnect.AppInstance{
Key: edgeconnect.AppInstanceKey{
Organization: action.Target.Organization,
Organization: action.Desired.Organization,
Name: action.InstanceName,
CloudletKey: edgeconnect.CloudletKey{
Organization: action.Target.CloudletOrg,
@ -438,9 +457,9 @@ func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAc
},
},
AppKey: edgeconnect.AppKey{
Organization: action.Target.Organization,
Organization: action.Desired.Organization,
Name: config.Metadata.Name,
Version: config.Spec.GetAppVersion(),
Version: config.Metadata.AppVersion,
},
Flavor: edgeconnect.Flavor{
Name: action.Target.FlavorName,
@ -503,3 +522,27 @@ func (r *RecreateStrategy) logf(format string, v ...interface{}) {
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

@ -1,13 +1,14 @@
// ABOUTME: Deployment planning types for EdgeConnect apply command with state management
// ABOUTME: Defines structures for deployment plans, actions, and state comparison results
package apply
package v1
import (
"fmt"
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
"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)
@ -353,40 +354,50 @@ func (dp *DeploymentPlan) GenerateSummary() string {
return "No changes required - configuration matches current state"
}
summary := fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName))
// App actions
if dp.AppAction.Type != ActionNone {
summary += fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name)
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 {
summary += fmt.Sprintf(" - %s\n", change)
sb.WriteString(fmt.Sprintf(" - %s\n", change))
}
}
}
// Instance actions
createCount := 0
updateCount := 0
updateActions := []InstanceAction{}
for _, action := range dp.InstanceActions {
switch action.Type {
case ActionCreate:
createCount++
case ActionUpdate:
updateCount++
updateActions = append(updateActions, action)
}
}
if createCount > 0 {
summary += fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets()))
}
if updateCount > 0 {
summary += fmt.Sprintf("- UPDATE %d instance(s)\n", updateCount)
sb.WriteString(fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets())))
}
summary += fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String())
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))
}
}
}
}
return summary
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

View file

@ -0,0 +1,434 @@
// 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

@ -0,0 +1,603 @@
// 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

@ -0,0 +1,556 @@
// 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 v2
import (
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"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"
)
// 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
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 := v2.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 := v2.AppInstanceKey{
Organization: desired.Organization,
Name: desired.Name,
CloudletKey: v2.CloudletKey{
Organization: desired.CloudletOrg,
Name: desired.CloudletName,
},
}
appKey := v2.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

@ -0,0 +1,663 @@
// 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

@ -0,0 +1,106 @@
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
package v2
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

@ -0,0 +1,641 @@
// 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
}

489
internal/apply/v2/types.go Normal file
View file

@ -0,0 +1,489 @@
// 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

@ -14,7 +14,7 @@ func TestParseExampleConfig(t *testing.T) {
parser := NewParser()
// Parse the actual example file (now that we've created the manifest file)
examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig.yaml")
examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig_v1.yaml")
config, parsedManifest, err := parser.ParseFile(examplePath)
// This should now succeed with full validation
@ -28,14 +28,13 @@ func TestParseExampleConfig(t *testing.T) {
// Check k8s app configuration
require.NotNil(t, config.Spec.K8sApp)
assert.Equal(t, "1.0.0", config.Spec.K8sApp.AppVersion)
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
require.Len(t, config.Spec.InfraTemplate, 1)
infra := config.Spec.InfraTemplate[0]
assert.Equal(t, "edp2", infra.Organization)
assert.Equal(t, "EU", infra.Region)
assert.Equal(t, "TelekomOP", infra.CloudletOrg)
assert.Equal(t, "Munich", infra.CloudletName)
@ -59,7 +58,6 @@ func TestParseExampleConfig(t *testing.T) {
// Test utility methods
assert.Equal(t, "edge-app-demo", config.Metadata.Name)
assert.Equal(t, "1.0.0", config.Spec.GetAppVersion())
assert.Contains(t, config.Spec.GetManifestFile(), "k8s-deployment.yaml")
assert.True(t, config.Spec.IsK8sApp())
assert.False(t, config.Spec.IsDockerApp())
@ -73,15 +71,15 @@ func TestValidateExampleStructure(t *testing.T) {
Kind: "edgeconnect-deployment",
Metadata: Metadata{
Name: "edge-app-demo",
AppVersion: "1.0.0",
Organization: "edp2",
},
Spec: Spec{
DockerApp: &DockerApp{ // Use DockerApp to avoid manifest file validation
AppVersion: "1.0.0",
Image: "nginx:latest",
},
InfraTemplate: []InfraTemplate{
{
Organization: "edp2",
Region: "EU",
CloudletOrg: "TelekomOP",
CloudletName: "Munich",

View file

@ -32,13 +32,13 @@ func TestConfigParser_ParseBytes(t *testing.T) {
kind: edgeconnect-deployment
metadata:
name: "test-app"
appVersion: "1.0.0"
organization: "testorg"
spec:
k8sApp:
appVersion: "1.0.0"
manifestFile: "./test-manifest.yaml"
infraTemplate:
- organization: "testorg"
region: "US"
- region: "US"
cloudletOrg: "TestOP"
cloudletName: "TestCloudlet"
flavorName: "small"
@ -52,13 +52,13 @@ spec:
kind: edgeconnect-deployment
metadata:
name: "test-app"
appVersion: "1.0.0"
organization: "testorg"
spec:
dockerApp:
appVersion: "1.0.0"
image: "nginx:latest"
infraTemplate:
- organization: "testorg"
region: "US"
- region: "US"
cloudletOrg: "TestOP"
cloudletName: "TestCloudlet"
flavorName: "small"
@ -70,13 +70,13 @@ spec:
yaml: `
metadata:
name: "test-app"
appVersion: "1.0.0"
organization: "testorg"
spec:
k8sApp:
appVersion: "1.0.0"
manifestFile: "./test-manifest.yaml"
infraTemplate:
- organization: "testorg"
region: "US"
- region: "US"
cloudletOrg: "TestOP"
cloudletName: "TestCloudlet"
flavorName: "small"
@ -90,13 +90,13 @@ spec:
kind: invalid-kind
metadata:
name: "test-app"
appVersion: "1.0.0"
organization: "testorg"
spec:
dockerApp:
appVersion: "1.0.0"
image: "nginx:latest"
infraTemplate:
- organization: "testorg"
region: "US"
- region: "US"
cloudletOrg: "TestOP"
cloudletName: "TestCloudlet"
flavorName: "small"
@ -110,10 +110,11 @@ spec:
kind: edgeconnect-deployment
metadata:
name: "test-app"
appVersion: "1.0.0"
organization: "testorg"
spec:
infraTemplate:
- organization: "testorg"
region: "US"
- region: "US"
cloudletOrg: "TestOP"
cloudletName: "TestCloudlet"
flavorName: "small"
@ -127,17 +128,16 @@ spec:
kind: edgeconnect-deployment
metadata:
name: "test-app"
appVersion: "1.0.0"
organization: "testorg"
spec:
k8sApp:
appVersion: "1.0.0"
manifestFile: "./test-manifest.yaml"
dockerApp:
appName: "test-app"
appVersion: "1.0.0"
image: "nginx:latest"
infraTemplate:
- organization: "testorg"
region: "US"
- region: "US"
cloudletOrg: "TestOP"
cloudletName: "TestCloudlet"
flavorName: "small"
@ -151,9 +151,10 @@ spec:
kind: edgeconnect-deployment
metadata:
name: "test-app"
appVersion: "1.0.0"
organization: "testorg"
spec:
dockerApp:
appVersion: "1.0.0"
image: "nginx:latest"
infraTemplate: []
`,
@ -166,13 +167,13 @@ spec:
kind: edgeconnect-deployment
metadata:
name: "test-app"
appVersion: "1.0.0"
organization: "testorg"
spec:
dockerApp:
appVersion: "1.0.0"
image: "nginx:latest"
infraTemplate:
- organization: "testorg"
region: "US"
- region: "US"
cloudletOrg: "TestOP"
cloudletName: "TestCloudlet"
flavorName: "small"
@ -222,13 +223,13 @@ func TestConfigParser_ParseFile(t *testing.T) {
kind: edgeconnect-deployment
metadata:
name: "test-app"
appVersion: "1.0.0"
organization: "testorg"
spec:
dockerApp:
appVersion: "1.0.0"
image: "nginx:latest"
infraTemplate:
- organization: "testorg"
region: "US"
- region: "US"
cloudletOrg: "TestOP"
cloudletName: "TestCloudlet"
flavorName: "small"
@ -284,13 +285,13 @@ func TestConfigParser_RelativePathResolution(t *testing.T) {
kind: edgeconnect-deployment
metadata:
name: "test-app"
appVersion: "1.0.0"
organization: "testorg"
spec:
k8sApp:
appVersion: "1.0.0"
manifestFile: "./manifest.yaml"
infraTemplate:
- organization: "testorg"
region: "US"
- region: "US"
cloudletOrg: "TestOP"
cloudletName: "TestCloudlet"
flavorName: "small"
@ -322,15 +323,15 @@ func TestEdgeConnectConfig_Validate(t *testing.T) {
Kind: "edgeconnect-deployment",
Metadata: Metadata{
Name: "test-app",
AppVersion: "1.0.0",
Organization: "testorg",
},
Spec: Spec{
DockerApp: &DockerApp{
AppVersion: "1.0.0",
Image: "nginx:latest",
},
InfraTemplate: []InfraTemplate{
{
Organization: "testorg",
Region: "US",
CloudletOrg: "TestOP",
CloudletName: "TestCloudlet",
@ -385,24 +386,60 @@ func TestMetadata_Validate(t *testing.T) {
}{
{
name: "valid metadata",
metadata: Metadata{Name: "test-app"},
metadata: Metadata{Name: "test-app", AppVersion: "1.0.0", Organization: "testorg"},
wantErr: false,
},
{
name: "empty name",
metadata: Metadata{Name: ""},
metadata: Metadata{Name: "", AppVersion: "1.0.0", Organization: "testorg"},
wantErr: true,
errMsg: "metadata.name is required",
},
{
name: "name with leading whitespace",
metadata: Metadata{Name: " test-app"},
metadata: Metadata{Name: " test-app", AppVersion: "1.0.0", Organization: "testorg"},
wantErr: true,
errMsg: "cannot have leading/trailing whitespace",
},
{
name: "name with trailing whitespace",
metadata: Metadata{Name: "test-app "},
metadata: Metadata{Name: "test-app ", AppVersion: "1.0.0", Organization: "testorg"},
wantErr: true,
errMsg: "cannot have leading/trailing whitespace",
},
{
name: "empty app version",
metadata: Metadata{Name: "test-app", AppVersion: "", Organization: "testorg"},
wantErr: true,
errMsg: "metadata.appVersion is required",
},
{
name: "app version with leading whitespace",
metadata: Metadata{Name: "test-app", AppVersion: " 1.0.0", Organization: "testorg"},
wantErr: true,
errMsg: "cannot have leading/trailing whitespace",
},
{
name: "app version with trailing whitespace",
metadata: Metadata{Name: "test-app", AppVersion: "1.0.0 ", Organization: "testorg"},
wantErr: true,
errMsg: "cannot have leading/trailing whitespace",
},
{
name: "empty organization",
metadata: Metadata{Name: "test-app", AppVersion: "1.0.0", Organization: ""},
wantErr: true,
errMsg: "metadata.organization is required",
},
{
name: "organization with leading whitespace",
metadata: Metadata{Name: "test-app", AppVersion: "1.0.0", Organization: " testorg"},
wantErr: true,
errMsg: "cannot have leading/trailing whitespace",
},
{
name: "organization with trailing whitespace",
metadata: Metadata{Name: "test-app", AppVersion: "1.0.0", Organization: "testorg "},
wantErr: true,
errMsg: "cannot have leading/trailing whitespace",
},
@ -526,24 +563,20 @@ func TestOutboundConnection_Validate(t *testing.T) {
func TestSpec_GetMethods(t *testing.T) {
k8sSpec := &Spec{
K8sApp: &K8sApp{
AppVersion: "1.0.0",
ManifestFile: "k8s.yaml",
},
}
dockerSpec := &Spec{
DockerApp: &DockerApp{
AppVersion: "2.0.0",
ManifestFile: "docker.yaml",
},
}
assert.Equal(t, "1.0.0", k8sSpec.GetAppVersion())
assert.Equal(t, "k8s.yaml", k8sSpec.GetManifestFile())
assert.True(t, k8sSpec.IsK8sApp())
assert.False(t, k8sSpec.IsDockerApp())
assert.Equal(t, "2.0.0", dockerSpec.GetAppVersion())
assert.Equal(t, "docker.yaml", dockerSpec.GetManifestFile())
assert.False(t, dockerSpec.IsK8sApp())
assert.True(t, dockerSpec.IsDockerApp())
@ -564,13 +597,13 @@ func TestReadManifestFile(t *testing.T) {
kind: edgeconnect-deployment
metadata:
name: "test-app"
appVersion: "1.0.0"
organization: "testorg"
spec:
k8sApp:
appVersion: "1.0.0"
manifestFile: "./manifest.yaml"
infraTemplate:
- organization: "testorg"
region: "US"
- region: "US"
cloudletOrg: "TestOP"
cloudletName: "TestCloudlet"
flavorName: "small"

View file

@ -7,6 +7,8 @@ import (
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// EdgeConnectConfig represents the top-level configuration structure
@ -19,6 +21,8 @@ type EdgeConnectConfig struct {
// Metadata contains configuration metadata
type Metadata struct {
Name string `yaml:"name"`
AppVersion string `yaml:"appVersion"`
Organization string `yaml:"organization"`
}
// Spec defines the application and infrastructure specification
@ -32,20 +36,17 @@ type Spec struct {
// K8sApp defines Kubernetes application configuration
type K8sApp struct {
AppVersion string `yaml:"appVersion"`
ManifestFile string `yaml:"manifestFile"`
}
// DockerApp defines Docker application configuration
type DockerApp struct {
AppVersion string `yaml:"appVersion"`
ManifestFile string `yaml:"manifestFile"`
Image string `yaml:"image"`
}
// InfraTemplate defines infrastructure deployment targets
type InfraTemplate struct {
Organization string `yaml:"organization"`
Region string `yaml:"region"`
CloudletOrg string `yaml:"cloudletOrg"`
CloudletName string `yaml:"cloudletName"`
@ -99,10 +100,75 @@ func (c *EdgeConnectConfig) GetImagePath() string {
if c.Spec.IsDockerApp() && 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"
}
// 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
func (m *Metadata) Validate() error {
if m.Name == "" {
@ -113,6 +179,22 @@ func (m *Metadata) Validate() error {
return fmt.Errorf("metadata.name cannot have leading/trailing whitespace")
}
if m.AppVersion == "" {
return fmt.Errorf("metadata.appVersion is required")
}
if strings.TrimSpace(m.AppVersion) != m.AppVersion {
return fmt.Errorf("metadata.appVersion cannot have leading/trailing whitespace")
}
if m.Organization == "" {
return fmt.Errorf("metadata.organization is required")
}
if strings.TrimSpace(m.Organization) != m.Organization {
return fmt.Errorf("metadata.Organization cannot have leading/trailing whitespace")
}
return nil
}
@ -171,10 +253,6 @@ func (s *Spec) Validate() error {
// Validate validates k8s app configuration
func (k *K8sApp) Validate() error {
if k.AppVersion == "" {
return fmt.Errorf("appVersion is required")
}
if k.ManifestFile == "" {
return fmt.Errorf("manifestFile is required")
}
@ -184,29 +262,15 @@ func (k *K8sApp) Validate() error {
return fmt.Errorf("manifestFile does not exist: %s", k.ManifestFile)
}
// Validate version format
if strings.TrimSpace(k.AppVersion) != k.AppVersion {
return fmt.Errorf("appVersion cannot have leading/trailing whitespace")
}
return nil
}
// Validate validates docker app configuration
func (d *DockerApp) Validate() error {
if d.AppVersion == "" {
return fmt.Errorf("appVersion is required")
}
if d.Image == "" {
return fmt.Errorf("image is required")
}
// Validate version format
if strings.TrimSpace(d.AppVersion) != d.AppVersion {
return fmt.Errorf("appVersion cannot have leading/trailing whitespace")
}
// Check if manifest file exists if specified
if d.ManifestFile != "" {
if _, err := os.Stat(d.ManifestFile); os.IsNotExist(err) {
@ -219,10 +283,6 @@ func (d *DockerApp) Validate() error {
// Validate validates infrastructure template configuration
func (i *InfraTemplate) Validate() error {
if i.Organization == "" {
return fmt.Errorf("organization is required")
}
if i.Region == "" {
return fmt.Errorf("region is required")
}
@ -241,7 +301,6 @@ func (i *InfraTemplate) Validate() error {
// Validate no leading/trailing whitespace
fields := map[string]string{
"organization": i.Organization,
"region": i.Region,
"cloudletOrg": i.CloudletOrg,
"cloudletName": i.CloudletName,
@ -326,17 +385,6 @@ func (d *DockerApp) GetManifestPath(configDir string) string {
return filepath.Join(configDir, d.ManifestFile)
}
// GetAppVersion returns the application version from the active app type
func (s *Spec) GetAppVersion() string {
if s.K8sApp != nil {
return s.K8sApp.AppVersion
}
if s.DockerApp != nil {
return s.DockerApp.AppVersion
}
return ""
}
// GetManifestFile returns the manifest file path from the active app type
func (s *Spec) GetManifestFile() string {
if s.K8sApp != nil {

View file

@ -0,0 +1,166 @@
// 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

@ -0,0 +1,229 @@
// 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
}

157
internal/delete/v1/types.go Normal file
View file

@ -0,0 +1,157 @@
// 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

@ -0,0 +1,166 @@
// 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

@ -0,0 +1,200 @@
// 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

@ -0,0 +1,229 @@
// 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

@ -0,0 +1,219 @@
// 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)
}

157
internal/delete/v2/types.go Normal file
View file

@ -0,0 +1,157 @@
// 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

@ -0,0 +1,95 @@
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)")
}

View file

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

BIN
public.gpg Normal file

Binary file not shown.

View file

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

View file

@ -9,12 +9,13 @@ import (
"fmt"
"net/http"
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/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"
@ -22,12 +23,20 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp
if err != nil {
return fmt.Errorf("CreateAppInstance failed: %w", err)
}
defer resp.Body.Close()
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)
@ -36,12 +45,12 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp
// 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, region string) (AppInstance, error) {
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},
AppInstance: AppInstance{AppKey: appKey, Key: appInstKey},
Region: region,
}
@ -49,7 +58,9 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey,
if err != nil {
return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err)
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return AppInstance{}, fmt.Errorf("app instance %s/%s: %w",
@ -76,12 +87,12 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey,
// ShowAppInstances retrieves all application instances matching the filter criteria
// Maps to POST /auth/ctrl/ShowAppInst
func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, region string) ([]AppInstance, error) {
func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) ([]AppInstance, error) {
transport := c.getTransport()
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
filter := AppInstanceFilter{
AppInstance: AppInstance{Key: appInstKey},
AppInstance: AppInstance{Key: appInstKey, AppKey: appKey},
Region: region,
}
@ -89,7 +100,9 @@ func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey
if err != nil {
return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowAppInstances")
@ -118,7 +131,9 @@ func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstance
if err != nil {
return fmt.Errorf("UpdateAppInstance failed: %w", err)
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "UpdateAppInstance")
@ -145,7 +160,9 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK
if err != nil {
return fmt.Errorf("RefreshAppInstance failed: %w", err)
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "RefreshAppInstance")
@ -172,7 +189,9 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe
if err != nil {
return fmt.Errorf("DeleteAppInstance failed: %w", err)
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
@ -187,14 +206,45 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe
// 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]
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
}
responses = append(responses, response)
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
})
@ -202,25 +252,20 @@ func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result i
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{
// 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

View file

@ -22,6 +22,7 @@ func TestCreateAppInstance(t *testing.T) {
mockStatusCode int
mockResponse string
expectError bool
errorContains string
}{
{
name: "successful creation",
@ -63,6 +64,57 @@ func TestCreateAppInstance(t *testing.T) {
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 {
@ -74,7 +126,7 @@ func TestCreateAppInstance(t *testing.T) {
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(tt.mockStatusCode)
w.Write([]byte(tt.mockResponse))
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
@ -91,6 +143,9 @@ func TestCreateAppInstance(t *testing.T) {
// Verify results
if tt.expectError {
assert.Error(t, err)
if tt.errorContains != "" {
assert.Contains(t, err.Error(), tt.errorContains)
}
} else {
assert.NoError(t, err)
}
@ -101,6 +156,7 @@ func TestCreateAppInstance(t *testing.T) {
func TestShowAppInstance(t *testing.T) {
tests := []struct {
name string
appKey AppKey
appInstKey AppInstanceKey
region string
mockStatusCode int
@ -118,6 +174,7 @@ func TestShowAppInstance(t *testing.T) {
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"}}
@ -135,6 +192,7 @@ func TestShowAppInstance(t *testing.T) {
Name: "testcloudlet",
},
},
appKey: AppKey{Name: "test-app-id"},
region: "us-west",
mockStatusCode: 404,
mockResponse: "",
@ -152,7 +210,7 @@ func TestShowAppInstance(t *testing.T) {
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != "" {
w.Write([]byte(tt.mockResponse))
_, _ = w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
@ -164,7 +222,7 @@ func TestShowAppInstance(t *testing.T) {
// Execute test
ctx := context.Background()
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region)
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region)
// Verify results
if tt.expectError {
@ -199,14 +257,14 @@ func TestShowAppInstances(t *testing.T) {
{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}}
`
w.WriteHeader(200)
w.Write([]byte(response))
_, _ = w.Write([]byte(response))
}))
defer server.Close()
client := NewClient(server.URL)
ctx := context.Background()
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, "us-west")
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west")
require.NoError(t, err)
assert.Len(t, appInstances, 2)
@ -306,7 +364,7 @@ func TestUpdateAppInstance(t *testing.T) {
assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization)
w.WriteHeader(tt.mockStatusCode)
w.Write([]byte(tt.mockResponse))
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()

View file

@ -10,12 +10,13 @@ import (
"io"
"net/http"
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/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
@ -28,7 +29,9 @@ func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error {
if err != nil {
return fmt.Errorf("CreateApp failed: %w", err)
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "CreateApp")
@ -55,7 +58,9 @@ func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App
if err != nil {
return App{}, fmt.Errorf("ShowApp failed: %w", err)
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
@ -95,7 +100,9 @@ func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]
if err != nil {
return nil, fmt.Errorf("ShowApps failed: %w", err)
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
return nil, c.handleErrorResponse(resp, "ShowApps")
@ -124,7 +131,9 @@ func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error {
if err != nil {
return fmt.Errorf("UpdateApp failed: %w", err)
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return c.handleErrorResponse(resp, "UpdateApp")
@ -151,7 +160,9 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er
if err != nil {
return fmt.Errorf("DeleteApp failed: %w", err)
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
// 404 is acceptable for delete operations (already deleted)
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
@ -169,6 +180,10 @@ func (c *Client) parseStreamingResponse(resp *http.Response, result interface{})
var responses []Response[App]
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
@ -238,7 +253,9 @@ func (c *Client) handleErrorResponse(resp *http.Response, operation string) erro
bodyBytes := []byte{}
if resp.Body != nil {
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
bodyBytes, _ = io.ReadAll(resp.Body)
messages = append(messages, string(bodyBytes))
}

View file

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

View file

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

View file

@ -56,7 +56,7 @@ func TestUsernamePasswordProvider_Success(t *testing.T) {
// Return token
response := map[string]string{"token": "dynamic-token-456"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
_ = json.NewEncoder(w).Encode(response)
}))
defer loginServer.Close()
@ -75,7 +75,7 @@ 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"))
_, _ = w.Write([]byte("Invalid credentials"))
}))
defer loginServer.Close()
@ -99,7 +99,7 @@ func TestUsernamePasswordProvider_TokenCaching(t *testing.T) {
callCount++
response := map[string]string{"token": "cached-token-789"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
_ = json.NewEncoder(w).Encode(response)
}))
defer loginServer.Close()
@ -128,7 +128,7 @@ func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) {
callCount++
response := map[string]string{"token": "refreshed-token-999"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
_ = json.NewEncoder(w).Encode(response)
}))
defer loginServer.Close()
@ -157,7 +157,7 @@ func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) {
callCount++
response := map[string]string{"token": "new-token-after-invalidation"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
_ = json.NewEncoder(w).Encode(response)
}))
defer loginServer.Close()
@ -185,7 +185,7 @@ 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"))
_, _ = w.Write([]byte("invalid json response"))
}))
defer loginServer.Close()

View file

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

View file

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

View file

@ -271,6 +271,26 @@ 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"`

View file

@ -0,0 +1,293 @@
// 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

@ -0,0 +1,527 @@
// 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)
}
})
}
}

213
sdk/edgeconnect/v2/apps.go Normal file
View file

@ -0,0 +1,213 @@
// 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

@ -0,0 +1,409 @@
// 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)
}

186
sdk/edgeconnect/v2/auth.go Normal file
View file

@ -0,0 +1,186 @@
// 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

@ -0,0 +1,226 @@
// 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

@ -0,0 +1,122 @@
// 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

@ -0,0 +1,283 @@
// 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

@ -0,0 +1,408 @@
// 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")
}
})
}
}

421
sdk/edgeconnect/v2/types.go Normal file
View file

@ -0,0 +1,421 @@
// 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
}

View file

@ -3,17 +3,17 @@
kind: edgeconnect-deployment
metadata:
name: "edge-app-demo" # name could be used for appName
appVersion: "1.0.0"
organization: "edp2"
spec:
# dockerApp: # Docker is OBSOLETE
# appVersion: "1.0.0"
# manifestFile: "./docker-compose.yaml"
# image: "https://registry-1.docker.io/library/nginx:latest"
k8sApp:
appVersion: "1.0.0"
manifestFile: "./k8s-deployment.yaml" # store hash of the manifest file in annotation field. Annotations is a comma separated map of arbitrary key value pairs,
manifestFile: "./k8s-deployment.yaml"
infraTemplate:
- organization: "edp2"
region: "EU"
- region: "EU"
cloudletOrg: "TelekomOP"
cloudletName: "Munich"
flavorName: "EU.small"

View file

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

View file

@ -18,6 +18,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: edgeconnect-coder-deployment
#namespace: gitea
spec:
replicas: 1
selector:
@ -32,7 +33,7 @@ spec:
volumes:
containers:
- name: edgeconnect-coder
image: nginx:latest
image: edp.buildth.ing/devfw-cicd/fibonacci_pipeline:edge_platform_demo
imagePullPolicy: Always
ports:
- containerPort: 80

View file

@ -12,7 +12,7 @@ import (
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
)
func main() {
@ -24,20 +24,20 @@ func main() {
username := getEnvOrDefault("EDGEXR_USERNAME", "")
password := getEnvOrDefault("EDGEXR_PASSWORD", "")
var client *edgeconnect.Client
var client *v2.Client
if token != "" {
fmt.Println("🔐 Using Bearer token authentication")
client = edgeconnect.NewClient(baseURL,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)),
edgeconnect.WithLogger(log.Default()),
client = v2.NewClient(baseURL,
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)),
v2.WithLogger(log.Default()),
)
} else if username != "" && password != "" {
fmt.Println("🔐 Using username/password authentication")
client = edgeconnect.NewClientWithCredentials(baseURL, username, password,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect.WithLogger(log.Default()),
client = v2.NewClientWithCredentials(baseURL, username, password,
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
v2.WithLogger(log.Default()),
)
} else {
log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD")
@ -85,15 +85,15 @@ type WorkflowConfig struct {
FlavorName string
}
func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config WorkflowConfig) error {
func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config WorkflowConfig) error {
fmt.Println("═══ Phase 1: Application Management ═══")
// 1. Create Application
fmt.Println("\n1⃣ Creating application...")
app := &edgeconnect.NewAppInput{
app := &v2.NewAppInput{
Region: config.Region,
App: edgeconnect.App{
Key: edgeconnect.AppKey{
App: v2.App{
Key: v2.AppKey{
Organization: config.Organization,
Name: config.AppName,
Version: config.AppVersion,
@ -101,10 +101,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
Deployment: "kubernetes",
ImageType: "ImageTypeDocker", // field is ignored
ImagePath: "https://registry-1.docker.io/library/nginx:latest", // must be set. Even for kubernetes
DefaultFlavor: edgeconnect.Flavor{Name: config.FlavorName},
DefaultFlavor: v2.Flavor{Name: config.FlavorName},
ServerlessConfig: struct{}{}, // must be set
AllowServerless: true, // must be set to true for kubernetes
RequiredOutboundConnections: []edgeconnect.SecurityRule{
RequiredOutboundConnections: []v2.SecurityRule{
{
Protocol: "tcp",
PortRangeMin: 80,
@ -128,7 +128,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
// 2. Show Application Details
fmt.Println("\n2⃣ Querying application details...")
appKey := edgeconnect.AppKey{
appKey := v2.AppKey{
Organization: config.Organization,
Name: config.AppName,
Version: config.AppVersion,
@ -146,7 +146,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
// 3. List Applications in Organization
fmt.Println("\n3⃣ Listing applications in organization...")
filter := edgeconnect.AppKey{Organization: config.Organization}
filter := v2.AppKey{Organization: config.Organization}
apps, err := c.ShowApps(ctx, filter, config.Region)
if err != nil {
return fmt.Errorf("failed to list apps: %w", err)
@ -160,19 +160,19 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
// 4. Create Application Instance
fmt.Println("\n4⃣ Creating application instance...")
instance := &edgeconnect.NewAppInstanceInput{
instance := &v2.NewAppInstanceInput{
Region: config.Region,
AppInst: edgeconnect.AppInstance{
Key: edgeconnect.AppInstanceKey{
AppInst: v2.AppInstance{
Key: v2.AppInstanceKey{
Organization: config.Organization,
Name: config.InstanceName,
CloudletKey: edgeconnect.CloudletKey{
CloudletKey: v2.CloudletKey{
Organization: config.CloudletOrg,
Name: config.CloudletName,
},
},
AppKey: appKey,
Flavor: edgeconnect.Flavor{Name: config.FlavorName},
Flavor: v2.Flavor{Name: config.FlavorName},
},
}
@ -184,16 +184,16 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
// 5. Wait for Application Instance to be Ready
fmt.Println("\n5⃣ Waiting for application instance to be ready...")
instanceKey := edgeconnect.AppInstanceKey{
instanceKey := v2.AppInstanceKey{
Organization: config.Organization,
Name: config.InstanceName,
CloudletKey: edgeconnect.CloudletKey{
CloudletKey: v2.CloudletKey{
Organization: config.CloudletOrg,
Name: config.CloudletName,
},
}
instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, config.Region, 5*time.Minute)
instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, v2.AppKey{}, config.Region, 5*time.Minute)
if err != nil {
return fmt.Errorf("failed to wait for instance ready: %w", err)
}
@ -207,7 +207,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
// 6. List Application Instances
fmt.Println("\n6⃣ Listing application instances...")
instances, err := c.ShowAppInstances(ctx, edgeconnect.AppInstanceKey{Organization: config.Organization}, config.Region)
instances, err := c.ShowAppInstances(ctx, v2.AppInstanceKey{Organization: config.Organization}, v2.AppKey{}, config.Region)
if err != nil {
return fmt.Errorf("failed to list app instances: %w", err)
}
@ -228,7 +228,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
// 8. Show Cloudlet Details
fmt.Println("\n8⃣ Querying cloudlet information...")
cloudletKey := edgeconnect.CloudletKey{
cloudletKey := v2.CloudletKey{
Organization: config.CloudletOrg,
Name: config.CloudletName,
}
@ -287,7 +287,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
// 13. Verify Cleanup
fmt.Println("\n1⃣3⃣ Verifying cleanup...")
_, err = c.ShowApp(ctx, appKey, config.Region)
if err != nil && fmt.Sprintf("%v", err) == edgeconnect.ErrResourceNotFound.Error() {
if err != nil && fmt.Sprintf("%v", err) == v2.ErrResourceNotFound.Error() {
fmt.Printf("✅ Cleanup verified - app no longer exists\n")
} else if err != nil {
fmt.Printf("✅ Cleanup appears successful (verification returned: %v)\n", err)
@ -306,7 +306,7 @@ func getEnvOrDefault(key, defaultValue string) string {
}
// waitForInstanceReady polls the instance status until it's no longer "Creating" or timeout
func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKey edgeconnect.AppInstanceKey, region string, timeout time.Duration) (edgeconnect.AppInstance, error) {
func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string, timeout time.Duration) (v2.AppInstance, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
@ -318,10 +318,10 @@ func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKe
for {
select {
case <-timeoutCtx.Done():
return edgeconnect.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout)
return v2.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout)
case <-ticker.C:
instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, region)
instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, appKey, region)
if err != nil {
// Log error but continue polling
fmt.Printf(" ⚠️ Error checking instance state: %v\n", err)

View file

@ -12,7 +12,7 @@ import (
"strings"
"time"
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
)
func main() {
@ -24,22 +24,22 @@ func main() {
username := getEnvOrDefault("EDGEXR_USERNAME", "")
password := getEnvOrDefault("EDGEXR_PASSWORD", "")
var edgeClient *edgeconnect.Client
var edgeClient *v2.Client
if token != "" {
// Use static token authentication
fmt.Println("🔐 Using Bearer token authentication")
edgeClient = edgeconnect.NewClient(baseURL,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)),
edgeconnect.WithLogger(log.Default()),
edgeClient = v2.NewClient(baseURL,
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)),
v2.WithLogger(log.Default()),
)
} else if username != "" && password != "" {
// Use username/password authentication (matches existing client pattern)
fmt.Println("🔐 Using username/password authentication")
edgeClient = edgeconnect.NewClientWithCredentials(baseURL, username, password,
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
edgeconnect.WithLogger(log.Default()),
edgeClient = v2.NewClientWithCredentials(baseURL, username, password,
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
v2.WithLogger(log.Default()),
)
} else {
log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD")
@ -48,10 +48,10 @@ func main() {
ctx := context.Background()
// Example application to deploy
app := &edgeconnect.NewAppInput{
app := &v2.NewAppInput{
Region: "EU",
App: edgeconnect.App{
Key: edgeconnect.AppKey{
App: v2.App{
Key: v2.AppKey{
Organization: "edp2",
Name: "my-edge-app",
Version: "1.0.0",
@ -59,7 +59,7 @@ func main() {
Deployment: "docker",
ImageType: "ImageTypeDocker",
ImagePath: "https://registry-1.docker.io/library/nginx:latest",
DefaultFlavor: edgeconnect.Flavor{Name: "EU.small"},
DefaultFlavor: v2.Flavor{Name: "EU.small"},
ServerlessConfig: struct{}{},
AllowServerless: false,
},
@ -73,7 +73,7 @@ func main() {
fmt.Println("✅ SDK example completed successfully!")
}
func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client, input *edgeconnect.NewAppInput) error {
func demonstrateAppLifecycle(ctx context.Context, edgeClient *v2.Client, input *v2.NewAppInput) error {
appKey := input.App.Key
region := input.Region
@ -98,7 +98,7 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client
// Step 3: List applications in the organization
fmt.Println("\n3. Listing applications...")
filter := edgeconnect.AppKey{Organization: appKey.Organization}
filter := v2.AppKey{Organization: appKey.Organization}
apps, err := edgeClient.ShowApps(ctx, filter, region)
if err != nil {
return fmt.Errorf("failed to list apps: %w", err)
@ -116,7 +116,7 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client
fmt.Println("\n5. Verifying deletion...")
_, err = edgeClient.ShowApp(ctx, appKey, region)
if err != nil {
if strings.Contains(fmt.Sprintf("%v", err), edgeconnect.ErrResourceNotFound.Error()) {
if strings.Contains(fmt.Sprintf("%v", err), v2.ErrResourceNotFound.Error()) {
fmt.Printf("✅ App successfully deleted (not found)\n")
} else {
return fmt.Errorf("unexpected error verifying deletion: %w", err)

View file

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

View file

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

View file

@ -0,0 +1,53 @@
# Forgejo Runner in Edge Connect Example
Execute in the projects main directory:
```
go run . apply -f forgejo-runner/EdgeConnectConfig.yaml
```
## Improvement: 'create app instance' with full respone body analysis (feature/parsing_createappinstance)
When we have an errorneous deployment (example: "namespace: gitea" within the manifest) EdgeConnect will reject the deployment at some stage in its creation workflow.
Now we grab the error correctly in the workflow-response-array by parsing the whole response body and don't think the deployment worked just of only reading the first successfull step (which was the creation of the app instance):
```bash
(devbox) stl@ubuntu-vpn:~/git/mms/ipcei-cis/edge-connect-client$ go run . apply -f sdk/examples/forgejo-runner/EdgeConnectConfig.yaml
📄 Loading configuration from: /home/stl/git/mms/ipcei-cis/edge-connect-client/sdk/examples/forgejo-runner/EdgeConnectConfig.yaml
✅ Configuration loaded successfully: forgejo-runner-edge
🔍 Analyzing current state and generating deployment plan...
📋 Deployment Plan:
==================================================
Deployment plan for 'forgejo-runner-edge':
- CREATE application 'forgejo-runner-edge'
- Create new application
- CREATE 1 instance(s) across 1 cloudlet(s)
Estimated duration: 3m0s
==================================================
This will perform 2 actions. Estimated time: 3m0s
Do you want to proceed? (yes/no): yes
🚀 Starting deployment...
2025/10/16 16:58:08 [ResourceManager] Starting deployment: forgejo-runner-edge
2025/10/16 16:58:08 [ResourceManager] Validating deployment prerequisites for: forgejo-runner-edge
2025/10/16 16:58:08 [ResourceManager] Prerequisites validation passed
2025/10/16 16:58:08 [ResourceManager] Using deployment strategy: recreate
2025/10/16 16:58:08 [ResourceManager] Estimated deployment duration: 8m20s
2025/10/16 16:58:08 [RecreateStrategy] Starting recreate deployment strategy for: forgejo-runner-edge
2025/10/16 16:58:08 [RecreateStrategy] Phase 1: Deleting existing instances
2025/10/16 16:58:08 [RecreateStrategy] No existing instances to delete
2025/10/16 16:58:08 [RecreateStrategy] Phase 2: No app deletion needed (new app)
2025/10/16 16:58:08 [RecreateStrategy] Phase 3: Creating application
2025/10/16 16:58:10 [RecreateStrategy] Successfully created application: edp2/forgejo-runner-edge version 1.0.0
2025/10/16 16:58:10 [RecreateStrategy] Phase 3 complete: app created successfully
2025/10/16 16:58:10 [RecreateStrategy] Phase 4: Creating new instances
2025/10/16 16:58:11 [RecreateStrategy] Failed to create instance forgejo-runner-edge-1.0.0-instance: failed to create instance: ShowAppInstance failed to parse response: API error: {"status_code":400,"code":"400","messages":["Encountered failures: Create App Inst failed: deployments.apps is forbidden: User \"system:serviceaccount:edgexr:crm-telekomop-hamburg\" cannot create resource \"deployments\" in API group \"apps\" in the namespace \"gitea\"","Creating","a service has been configured","your application is accessiable via https://forgejo-runner-test-tcp.apps.edge.platform.mg3.mdb.osc.live","CreateError","Deleting AppInst due to failure","Deleted AppInst successfully"]} (non-retryable error, giving up)
2025/10/16 16:58:11 [ResourceManager] Deployment failed, attempting rollback...
2025/10/16 16:58:11 [ResourceManager] Starting rollback for deployment: forgejo-runner-edge
2025/10/16 16:58:11 [ResourceManager] Successfully rolled back: forgejo-runner-edge
2025/10/16 16:58:11 [ResourceManager] Rollback completed successfully
Error: deployment failed: failed to create instance forgejo-runner-edge-1.0.0-instance: non-retryable error: failed to create instance: ShowAppInstance failed to parse response: API error: {"status_code":400,"code":"400","messages":["Encountered failures: Create App Inst failed: deployments.apps is forbidden: User \"system:serviceaccount:edgexr:crm-telekomop-hamburg\" cannot create resource \"deployments\" in API group \"apps\" in the namespace \"gitea\"","Creating","a service has been configured","your application is accessiable via https://forgejo-runner-test-tcp.apps.edge.platform.mg3.mdb.osc.live","CreateError","Deleting AppInst due to failure","Deleted AppInst successfully"]}
exit status 1
```

View file

@ -0,0 +1,108 @@
apiVersion: v1
kind: Service
metadata:
name: forgejo-runner-test-tcp
labels:
app: forgejo-runner-test
spec:
type: LoadBalancer
ports:
- name: tcp80
protocol: TCP
port: 80
targetPort: 80
selector:
app: forgejo-runner-test
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: forgejo-runner-test
name: forgejo-runner-test
#namespace: gitea
spec:
# Two replicas means that if one is busy, the other can pick up jobs.
replicas: 3
selector:
matchLabels:
app: forgejo-runner-test
strategy: {}
template:
metadata:
creationTimestamp: null
labels:
app: forgejo-runner-test
spec:
restartPolicy: Always
volumes:
- name: docker-certs
emptyDir: {}
- name: runner-data
emptyDir: {}
# Initialise our configuration file using offline registration
# https://forgejo.org/docs/v1.21/admin/actions/#offline-registration
initContainers:
- name: runner-register
image: code.forgejo.org/forgejo/runner:6.4.0
command:
- "sh"
- "-c"
- |
forgejo-runner \
register \
--no-interactive \
--token "#####RUNNER_REGISTRATION_TOKEN#####" \
--name "edge-test" \
--instance "https://garm-provider-test.t09.de" \
--labels docker:docker://node:20-bookworm,ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04,ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04
volumeMounts:
- name: runner-data
mountPath: /data
containers:
- name: runner
image: code.forgejo.org/forgejo/runner:6.4.0
command:
- "sh"
- "-c"
- |
while ! nc -z 127.0.0.1 2376 </dev/null; do
echo 'waiting for docker daemon...';
sleep 5;
done
forgejo-runner generate-config > config.yml ;
sed -i -e "s|privileged: .*|privileged: true|" config.yml
sed -i -e "s|network: .*|network: host|" config.yml ;
sed -i -e "s|^ envs:$$| envs:\n DOCKER_HOST: tcp://127.0.0.1:2376\n DOCKER_TLS_VERIFY: 1\n DOCKER_CERT_PATH: /certs/client|" config.yml ;
sed -i -e "s|^ options:| options: -v /certs/client:/certs/client|" config.yml ;
sed -i -e "s| valid_volumes: \[\]$$| valid_volumes:\n - /certs/client|" config.yml ;
/bin/forgejo-runner --config config.yml daemon
securityContext:
allowPrivilegeEscalation: true
privileged: true
readOnlyRootFilesystem: false
runAsGroup: 0
runAsNonRoot: false
runAsUser: 0
env:
- name: DOCKER_HOST
value: tcp://localhost:2376
- name: DOCKER_CERT_PATH
value: /certs/client
- name: DOCKER_TLS_VERIFY
value: "1"
volumeMounts:
- name: docker-certs
mountPath: /certs
- name: runner-data
mountPath: /data
- name: daemon
image: docker:28.0.4-dind
env:
- name: DOCKER_TLS_CERTDIR
value: /certs
securityContext:
privileged: true
volumeMounts:
- name: docker-certs
mountPath: /certs

View file

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

View file

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

View file

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

View file

@ -98,10 +98,12 @@ func NewTransport(opts RetryOptions, auth AuthProvider, logger Logger) *Transpor
// Call executes an HTTP request with retry logic and returns typed response
func (t *Transport) Call(ctx context.Context, method, url string, body interface{}) (*http.Response, error) {
var reqBody io.Reader
var jsonData []byte
// Marshal request body if provided
if body != nil {
jsonData, err := json.Marshal(body)
var err error
jsonData, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
@ -127,8 +129,16 @@ func (t *Transport) Call(ctx context.Context, method, url string, body interface
// Log request
if t.logger != nil {
t.logger.Printf("HTTP %s %s", method, url)
t.logger.Printf("BODY %s", reqBody)
t.logger.Printf("=== HTTP REQUEST ===")
t.logger.Printf("%s %s", method, url)
if len(jsonData) > 0 {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, jsonData, "", " "); err == nil {
t.logger.Printf("Request Body:\n%s", prettyJSON.String())
} else {
t.logger.Printf("Request Body: %s", string(jsonData))
}
}
}
// Execute request
@ -139,7 +149,8 @@ func (t *Transport) Call(ctx context.Context, method, url string, body interface
// Log response
if t.logger != nil {
t.logger.Printf("HTTP %s %s -> %d", method, url, resp.StatusCode)
t.logger.Printf("=== HTTP RESPONSE ===")
t.logger.Printf("%s %s -> %d", method, url, resp.StatusCode)
}
return resp, nil
@ -151,7 +162,9 @@ func (t *Transport) CallJSON(ctx context.Context, method, url string, body inter
if err != nil {
return resp, err
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
// Read response body
respBody, err := io.ReadAll(resp.Body)