Compare commits
16 commits
main
...
feature/he
| Author | SHA1 | Date | |
|---|---|---|---|
| 488fe430fb | |||
| 00487bec7c | |||
| 054b1c91fc | |||
| 5918ba5db6 | |||
| 5ac67a224d | |||
| 1c13c93512 | |||
| a987e42ad6 | |||
| 8d6f51978d | |||
| 7b062612f5 | |||
| f1ee439c61 | |||
| 19a9807499 | |||
| 8e2e61d61e | |||
| 2625a58691 | |||
| 7b359f81e3 | |||
| e72c81bc43 | |||
| 43d8f277a6 |
112 changed files with 5047 additions and 14950 deletions
|
|
@ -1,14 +0,0 @@
|
||||||
# Example EdgeConnect CLI Configuration File
|
|
||||||
# Place this file at ~/.edge-connect.yaml or specify with --config flag
|
|
||||||
|
|
||||||
# Base URL for the EdgeConnect API
|
|
||||||
base_url: "https://hub.apps.edge.platform.mg3.mdb.osc.live"
|
|
||||||
|
|
||||||
# Authentication credentials
|
|
||||||
username: "your-username@example.com"
|
|
||||||
password: "your-password"
|
|
||||||
|
|
||||||
# API version to use (v1 or v2)
|
|
||||||
# Default: v2
|
|
||||||
# Set via config, --api-version flag, or EDGE_CONNECT_API_VERSION env var
|
|
||||||
api_version: "v2"
|
|
||||||
7
.github/workflows/release.yaml
vendored
7
.github/workflows/release.yaml
vendored
|
|
@ -19,16 +19,9 @@ jobs:
|
||||||
go-version: ">=1.25.1"
|
go-version: ">=1.25.1"
|
||||||
- name: Test code
|
- name: Test code
|
||||||
run: make test
|
run: make test
|
||||||
- name: Import GPG key
|
|
||||||
id: import_gpg
|
|
||||||
uses: https://github.com/crazy-max/ghaction-import-gpg@v6
|
|
||||||
with:
|
|
||||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
|
||||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: https://github.com/goreleaser/goreleaser-action@v6
|
uses: https://github.com/goreleaser/goreleaser-action@v6
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
|
||||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
|
||||||
with:
|
with:
|
||||||
args: release --clean
|
args: release --clean
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -2,8 +2,10 @@ edge-connect
|
||||||
# Added by goreleaser init:
|
# Added by goreleaser init:
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# ignore binaries
|
||||||
|
main
|
||||||
|
bin/
|
||||||
|
|
||||||
### direnv ###
|
### direnv ###
|
||||||
.direnv
|
.direnv
|
||||||
.envrc
|
.envrc
|
||||||
|
|
||||||
edge-connect-client
|
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,6 @@ archives:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
formats: [zip]
|
formats: [zip]
|
||||||
|
|
||||||
signs:
|
|
||||||
- artifacts: checksum
|
|
||||||
cmd: gpg
|
|
||||||
args:
|
|
||||||
- "--batch"
|
|
||||||
- "-u"
|
|
||||||
- "{{ .Env.GPG_FINGERPRINT }}"
|
|
||||||
- "--output"
|
|
||||||
- "${signature}"
|
|
||||||
- "--detach-sign"
|
|
||||||
- "${artifact}"
|
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
abbrev: 10
|
abbrev: 10
|
||||||
filters:
|
filters:
|
||||||
|
|
|
||||||
10
Makefile
10
Makefile
|
|
@ -13,22 +13,22 @@ test:
|
||||||
|
|
||||||
# Run tests with coverage
|
# Run tests with coverage
|
||||||
test-coverage:
|
test-coverage:
|
||||||
go test -v -coverprofile=coverage.out ./...
|
GOTOOLCHAIN=go1.25.1 go test -v -coverprofile=coverage.out ./...
|
||||||
go tool cover -html=coverage.out -o coverage.html
|
GOTOOLCHAIN=go1.25.1 go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
# Build the CLI
|
# Build the CLI
|
||||||
build:
|
build:
|
||||||
go build -o bin/edge-connect .
|
go build -o bin/edge-connect-cli ./cmd/cli
|
||||||
|
|
||||||
# Clean generated files and build artifacts
|
# Clean generated files and build artifacts
|
||||||
clean:
|
clean:
|
||||||
rm -f sdk/client/types_generated.go
|
rm -f sdk/client/types_generated.go
|
||||||
rm -f bin/edge-connect
|
rm -f bin/edge-connect-cli
|
||||||
rm -f coverage.out coverage.html
|
rm -f coverage.out coverage.html
|
||||||
|
|
||||||
# Lint the code
|
# Lint the code
|
||||||
lint:
|
lint:
|
||||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2 run
|
golangci-lint run
|
||||||
|
|
||||||
# Run all checks (generate, test, lint)
|
# Run all checks (generate, test, lint)
|
||||||
check: test lint
|
check: test lint
|
||||||
|
|
|
||||||
|
|
@ -1,868 +0,0 @@
|
||||||
{
|
|
||||||
"basePath": "/api/v1",
|
|
||||||
"definitions": {
|
|
||||||
"handler.App": {
|
|
||||||
"properties": {
|
|
||||||
"access_ports": {
|
|
||||||
"description": "Application port to be exposed with ingress in format <protocol:port>.\nNecessary only when manifest is generated automatically. Otherwise, all\nthe ports has to be set up manually in YAML manifest.",
|
|
||||||
"example": "tcp:80",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"allow_serverless": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"description": "Timestamp, format RFC3339.",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"defaultFlavor": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/handler.Flavor"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Default resource config to be used."
|
|
||||||
},
|
|
||||||
"deployment": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/handler.DeploymentType"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"example": "kubernetes"
|
|
||||||
},
|
|
||||||
"global_id": {
|
|
||||||
"description": "Combination of key fields (local-<key.name><key.version><key.organisation>).",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"image_path": {
|
|
||||||
"description": "Docker registry URL (e.g. docker.io/library/nginx:latest)",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"image_type": {
|
|
||||||
"$ref": "#/definitions/handler.ImageType"
|
|
||||||
},
|
|
||||||
"key": {
|
|
||||||
"$ref": "#/definitions/handler.AppKey"
|
|
||||||
},
|
|
||||||
"serverless_config": {
|
|
||||||
"$ref": "#/definitions/handler.ServerlessConfig"
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"description": "Timestamp, format RFC3339.",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.AppInst": {
|
|
||||||
"properties": {
|
|
||||||
"app_key": {
|
|
||||||
"$ref": "#/definitions/handler.AppKey"
|
|
||||||
},
|
|
||||||
"cloudlet_loc": {
|
|
||||||
"$ref": "#/definitions/handler.CloudletLoc"
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"flavor": {
|
|
||||||
"$ref": "#/definitions/handler.Flavor"
|
|
||||||
},
|
|
||||||
"ingress_url": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"key": {
|
|
||||||
"$ref": "#/definitions/handler.AppInstKey"
|
|
||||||
},
|
|
||||||
"state": {
|
|
||||||
"$ref": "#/definitions/handler.AppInstState"
|
|
||||||
},
|
|
||||||
"unique_id": {
|
|
||||||
"description": "Combination of key fields (<key.organisation>-<key.name>-<cloudletKey.name>-<cloudletKey.organisation>).",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.AppInstKey": {
|
|
||||||
"properties": {
|
|
||||||
"cloudlet_key": {
|
|
||||||
"$ref": "#/definitions/handler.CloudletKey"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"organization": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.AppInstState": {
|
|
||||||
"enum": [
|
|
||||||
"Ready"
|
|
||||||
],
|
|
||||||
"type": "string",
|
|
||||||
"x-enum-varnames": [
|
|
||||||
"AppInstStateReady"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"handler.AppKey": {
|
|
||||||
"description": "is a unique identifier.",
|
|
||||||
"properties": {
|
|
||||||
"name": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"organization": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"version": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.CloudletKey": {
|
|
||||||
"properties": {
|
|
||||||
"name": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"organization": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.CloudletLoc": {
|
|
||||||
"properties": {
|
|
||||||
"latitude": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"longitude": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.DeploymentType": {
|
|
||||||
"enum": [
|
|
||||||
"kubernetes"
|
|
||||||
],
|
|
||||||
"type": "string",
|
|
||||||
"x-enum-varnames": [
|
|
||||||
"DeploymentTypeKubernetes"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"handler.Flavor": {
|
|
||||||
"description": "is a default configuration is applied to app if no configuration\nis provided (e.g. in serverless config). Configuration can be checked at /auth/ctrl/CreateApp",
|
|
||||||
"properties": {
|
|
||||||
"name": {
|
|
||||||
"$ref": "#/definitions/handler.FlavorName"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.FlavorName": {
|
|
||||||
"enum": [
|
|
||||||
"EU.small",
|
|
||||||
"EU.medium",
|
|
||||||
"EU.big",
|
|
||||||
"EU.large"
|
|
||||||
],
|
|
||||||
"type": "string",
|
|
||||||
"x-enum-varnames": [
|
|
||||||
"DefaultFlavorNameEUSmall",
|
|
||||||
"DefaultFlavorNameEUMedium",
|
|
||||||
"DefaultFlavorNameEUBig",
|
|
||||||
"DefaultFlavorNameEULarge"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"handler.ImageType": {
|
|
||||||
"enum": [
|
|
||||||
"docker"
|
|
||||||
],
|
|
||||||
"type": "string",
|
|
||||||
"x-enum-varnames": [
|
|
||||||
"ImageTypeDocker"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"handler.Region": {
|
|
||||||
"enum": [
|
|
||||||
"EU"
|
|
||||||
],
|
|
||||||
"type": "string",
|
|
||||||
"x-enum-varnames": [
|
|
||||||
"RegionEU"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"handler.RequestCreateApp": {
|
|
||||||
"description": "necessary App details to create an entity.",
|
|
||||||
"properties": {
|
|
||||||
"app": {
|
|
||||||
"properties": {
|
|
||||||
"access_ports": {
|
|
||||||
"description": "Application port to be exposed with ingress in format <protocol:port>.\nNecessary only when manifest is generated automatically. Otherwise,\nall the ports has to be set up manually in YAML manifest.",
|
|
||||||
"example": "tcp:80",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"allow_serverless": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"defaultFlavor": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/handler.Flavor"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Default resource config to be used."
|
|
||||||
},
|
|
||||||
"deployment": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/handler.DeploymentType"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"example": "kubernetes"
|
|
||||||
},
|
|
||||||
"deployment_generator": {
|
|
||||||
"description": "Technical field. Required for providing custom manifest",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"deployment_manifest": {
|
|
||||||
"description": "Kubernetes manifest. ACCEPTS ONLY DEPLOYMENTS AND SERVICES.",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"image_path": {
|
|
||||||
"description": "Docker registry URL (e.g. docker.io/library/nginx:latest)",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"image_type": {
|
|
||||||
"$ref": "#/definitions/handler.ImageType"
|
|
||||||
},
|
|
||||||
"key": {
|
|
||||||
"$ref": "#/definitions/handler.AppKey"
|
|
||||||
},
|
|
||||||
"serverless_config": {
|
|
||||||
"$ref": "#/definitions/handler.ServerlessConfig"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"region": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/handler.Region"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Region to create instance at.",
|
|
||||||
"example": "EU"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"example": {
|
|
||||||
"appWithoutManifest": {
|
|
||||||
"region": "EU",
|
|
||||||
"app": {
|
|
||||||
"key": {
|
|
||||||
"organization": "DeveloperOrg",
|
|
||||||
"name": "test-app-without-manifest",
|
|
||||||
"version": "1.0"
|
|
||||||
},
|
|
||||||
"access_ports": "tcp:80",
|
|
||||||
"serverless_config": {},
|
|
||||||
"deployment": "kubernetes",
|
|
||||||
"image_type": "Docker",
|
|
||||||
"image_path": "docker.io/library/nginx:latest",
|
|
||||||
"allow_serverless": true,
|
|
||||||
"defaultFlavor": {
|
|
||||||
"name": "EU.small"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"appWitManifest": {
|
|
||||||
"region": "EU",
|
|
||||||
"app": {
|
|
||||||
"key": {
|
|
||||||
"organization": "DeveloperOrg",
|
|
||||||
"name": "test-app-without-manifest",
|
|
||||||
"version": "1.0"
|
|
||||||
},
|
|
||||||
"serverless_config": {},
|
|
||||||
"deployment": "kubernetes",
|
|
||||||
"image_type": "Docker",
|
|
||||||
"image_path": "docker.io/library/nginx:latest",
|
|
||||||
"allow_serverless": true,
|
|
||||||
"defaultFlavor": {
|
|
||||||
"name": "EU.small"
|
|
||||||
},
|
|
||||||
"deployment_manifest": "apiVersion: v1\nkind: Service\nmetadata:\n name: example-app-tcp\n labels:\n run: example-app\nspec:\n type: LoadBalancer\n ports:\n - name: tcp8080\n protocol: TCP\n port: 80\n targetPort: 80\n selector:\n run: example-app\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: example-app-deployment\nspec:\n replicas: 1\n selector:\n matchLabels:\n run: example-app\n template:\n metadata:\n labels:\n run: example-app\n mexDeployGen: kubernetes-basic\n spec:\n volumes:\n imagePullSecrets:\n - name: mtr.devops.telekom.de\n containers:\n - name: example-app\n image: docker.io/library/nginx:latest\n imagePullPolicy: Always\n ports:\n - containerPort: 80\n protocol: TCP\n",
|
|
||||||
"deployment_generator": "kubernetes-basic"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"handler.RequestCreateAppInst": {
|
|
||||||
"properties": {
|
|
||||||
"appinst": {
|
|
||||||
"properties": {
|
|
||||||
"app_key": {
|
|
||||||
"$ref": "#/definitions/handler.AppKey"
|
|
||||||
},
|
|
||||||
"key": {
|
|
||||||
"$ref": "#/definitions/handler.AppInstKey"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"region": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/handler.Region"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Region to create instance at.",
|
|
||||||
"example": "EU"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.RequestCreateAppInstMessage": {
|
|
||||||
"properties": {
|
|
||||||
"message": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.RequestDeleteApp": {
|
|
||||||
"properties": {
|
|
||||||
"key": {
|
|
||||||
"$ref": "#/definitions/handler.AppKey"
|
|
||||||
},
|
|
||||||
"region": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/handler.Region"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"example": "EU"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.RequestDeleteAppInst": {
|
|
||||||
"properties": {
|
|
||||||
"key": {
|
|
||||||
"$ref": "#/definitions/handler.AppInstKey"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.RequestShowApp": {
|
|
||||||
"properties": {
|
|
||||||
"access_ports": {
|
|
||||||
"description": "Application port to be exposed with ingress. Necessary only when\nmanifest is generated automatically. Otherwise, all the ports has to be\nset up manually in YAML manifest",
|
|
||||||
"example": "tcp:80",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"defaultFlavor": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/handler.Flavor"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Default resource config to be used."
|
|
||||||
},
|
|
||||||
"deployment": {
|
|
||||||
"$ref": "#/definitions/handler.DeploymentType"
|
|
||||||
},
|
|
||||||
"image_path": {
|
|
||||||
"description": "Docker registry URL (e.g. docker.io/library/nginx:latest)",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"image_type": {
|
|
||||||
"$ref": "#/definitions/handler.ImageType"
|
|
||||||
},
|
|
||||||
"key": {
|
|
||||||
"$ref": "#/definitions/handler.AppKey"
|
|
||||||
},
|
|
||||||
"region": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/handler.Region"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"example": "EU"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.RequestShowAppInst": {
|
|
||||||
"properties": {
|
|
||||||
"app_key": {
|
|
||||||
"$ref": "#/definitions/handler.AppKey"
|
|
||||||
},
|
|
||||||
"key": {
|
|
||||||
"$ref": "#/definitions/handler.AppInstKey"
|
|
||||||
},
|
|
||||||
"region": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/handler.Region"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Region to create instance at.",
|
|
||||||
"example": "EU"
|
|
||||||
},
|
|
||||||
"state": {
|
|
||||||
"$ref": "#/definitions/handler.AppInstState"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.RequestUpdateApp": {
|
|
||||||
"properties": {
|
|
||||||
"access_ports": {
|
|
||||||
"description": "Can be updated only if manifest is generated by EdgeXR.",
|
|
||||||
"example": "tcp:80",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"defaultFlavor": {
|
|
||||||
"$ref": "#/definitions/handler.Flavor"
|
|
||||||
},
|
|
||||||
"image_path": {
|
|
||||||
"description": "Docker registry URL (e.g. docker.io/library/nginx:latest)",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"key": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/handler.AppKey"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Immutable."
|
|
||||||
},
|
|
||||||
"region": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/handler.Region"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Immutable.",
|
|
||||||
"example": "EU"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.RequestUpdateAppInst": {
|
|
||||||
"properties": {
|
|
||||||
"flavor": {
|
|
||||||
"$ref": "#/definitions/handler.Flavor"
|
|
||||||
},
|
|
||||||
"key": {
|
|
||||||
"$ref": "#/definitions/handler.AppInstKey"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"handler.ServerlessConfig": {
|
|
||||||
"description": "is a default configuration is applied to app if no configuration\nis provided (e.g. in serverless config). Configuration can be checked at /auth/ctrl/CreateApp",
|
|
||||||
"properties": {
|
|
||||||
"min_replicas": {
|
|
||||||
"description": "number of replicas (at least 1).",
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"ram": {
|
|
||||||
"description": "RAM in MB.",
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"vcpus": {
|
|
||||||
"description": "Virtual CPUs.",
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"externalDocs": {
|
|
||||||
"description": "OpenAPI",
|
|
||||||
"url": "https://swagger.io/resources/open-api/"
|
|
||||||
},
|
|
||||||
"info": {
|
|
||||||
"contact": {},
|
|
||||||
"description": "# Introduction\n The Master Controller (MC) serves as the central\ngateway for orchestrating edge applications and provides several services to both\napplication developers and operators. For application developers, these APIs allow\nthe management and monitoring of deployments for edge applications. For infrastructure\noperators, these APIs provide ways to manage and monitor the usage of cloudlet\ninfrastructures. Both developers and operators can take advantage of these APIS\nto manage users within the Organization. ## Important note.\n API can return more\nfields than provided in the specification. Specification is a main source of truth.",
|
|
||||||
"license": {
|
|
||||||
"name": "Apache 2.0",
|
|
||||||
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
|
|
||||||
},
|
|
||||||
"title": "Edge Connect API",
|
|
||||||
"version": "2.0"
|
|
||||||
},
|
|
||||||
"paths": {
|
|
||||||
"/": {
|
|
||||||
"get": {
|
|
||||||
"description": "Returns OK when server is set-up and running.",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK\" \"ok",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Show server is running",
|
|
||||||
"tags": [
|
|
||||||
"monitoring"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/auth/ctrl/CreateApp": {
|
|
||||||
"post": {
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"description": "Creates App specification, validating of the params. Please, read\ndescription of the fields since not every one is required in every configuration.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"description": "body",
|
|
||||||
"in": "body",
|
|
||||||
"name": "_",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/handler.RequestCreateApp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad Request"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Creates App specification.",
|
|
||||||
"tags": [
|
|
||||||
"App"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/auth/ctrl/CreateAppInst": {
|
|
||||||
"post": {
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"description": "Create App instance on the cloudlet. Requests can be done as web\nsocket or regular http request returning all the steps as array of json messages.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"description": "body",
|
|
||||||
"in": "body",
|
|
||||||
"name": "_",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/handler.RequestCreateAppInst"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK",
|
|
||||||
"schema": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/definitions/handler.RequestCreateAppInstMessage"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad Request"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Create app instance.",
|
|
||||||
"tags": [
|
|
||||||
"AppInst"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/auth/ctrl/DeleteApp": {
|
|
||||||
"post": {
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"description": "Update app specification with limitation to the key.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"description": "body",
|
|
||||||
"in": "body",
|
|
||||||
"name": "_",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/handler.RequestDeleteApp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad Request"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Update app specs.",
|
|
||||||
"tags": [
|
|
||||||
"App"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/auth/ctrl/DeleteAppInst": {
|
|
||||||
"post": {
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"description": "Deletes app instance at the specified cloudlet.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"description": "body",
|
|
||||||
"in": "body",
|
|
||||||
"name": "_",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/handler.RequestDeleteAppInst"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad Request"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Deletes app instance by key.",
|
|
||||||
"tags": [
|
|
||||||
"AppInst"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/auth/ctrl/ShowApp": {
|
|
||||||
"post": {
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"description": "returns app specifications for provided region. Filter is done\nwith providing app fields from the model.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"description": "body",
|
|
||||||
"in": "body",
|
|
||||||
"name": "_",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/handler.RequestShowApp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK",
|
|
||||||
"schema": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/definitions/handler.App"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad Request"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Shows list of apps for the region.",
|
|
||||||
"tags": [
|
|
||||||
"App"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/auth/ctrl/ShowAppInst": {
|
|
||||||
"post": {
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"description": "Returns app instances for provided region. Filter is done with\nproviding app instances fields from the model.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"description": "body",
|
|
||||||
"in": "body",
|
|
||||||
"name": "_",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/handler.RequestShowAppInst"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK",
|
|
||||||
"schema": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/definitions/handler.AppInst"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad Request"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Shows list of app instances for the region.",
|
|
||||||
"tags": [
|
|
||||||
"AppInst"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/auth/ctrl/UpdateApp": {
|
|
||||||
"post": {
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"description": "Update app specification with limitation to the key.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"description": "body",
|
|
||||||
"in": "body",
|
|
||||||
"name": "_",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/handler.RequestUpdateApp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad Request"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Update app specs.",
|
|
||||||
"tags": [
|
|
||||||
"App"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/auth/ctrl/UpdateAppInst": {
|
|
||||||
"post": {
|
|
||||||
"consumes": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"description": "Update app instance by key with limited set of fields.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"description": "body",
|
|
||||||
"in": "body",
|
|
||||||
"name": "_",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/handler.RequestUpdateAppInst"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"produces": [
|
|
||||||
"application/json"
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad Request"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Update app instance by key.",
|
|
||||||
"tags": [
|
|
||||||
"AppInst"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"securityDefinitions": {
|
|
||||||
"Bearer": {
|
|
||||||
"type": "basic"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"swagger": "2.0"
|
|
||||||
}
|
|
||||||
308
cmd/app.go
308
cmd/app.go
|
|
@ -1,308 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
|
||||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
organization string
|
|
||||||
appName string
|
|
||||||
appVersion string
|
|
||||||
region string
|
|
||||||
)
|
|
||||||
|
|
||||||
func validateBaseURL(baseURL string) error {
|
|
||||||
url, err := url.Parse(baseURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("decoding error '%s'", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
if url.Scheme == "" {
|
|
||||||
return fmt.Errorf("schema should be set (add https://)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(url.User.Username()) > 0 {
|
|
||||||
return fmt.Errorf("user and or password should not be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
if url.Path != "" && url.Path != "/" {
|
|
||||||
return fmt.Errorf("should not contain any path '%s'", url.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(url.Query()) > 0 {
|
|
||||||
return fmt.Errorf("should not contain any queries '%s'", url.RawQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(url.Fragment) > 0 {
|
|
||||||
return fmt.Errorf("should not contain any fragment '%s'", url.Fragment)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAPIVersion() string {
|
|
||||||
version := viper.GetString("api_version")
|
|
||||||
if version == "" {
|
|
||||||
version = "v2" // default to v2
|
|
||||||
}
|
|
||||||
return strings.ToLower(version)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSDKClientV1() *edgeconnect.Client {
|
|
||||||
baseURL := viper.GetString("base_url")
|
|
||||||
username := viper.GetString("username")
|
|
||||||
password := viper.GetString("password")
|
|
||||||
|
|
||||||
err := validateBaseURL(baseURL)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error parsing baseURL: '%s' with error: %s\n", baseURL, err.Error())
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build options
|
|
||||||
opts := []edgeconnect.Option{
|
|
||||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add logger only if debug flag is set
|
|
||||||
if debug {
|
|
||||||
logger := log.New(os.Stderr, "[DEBUG] ", log.LstdFlags)
|
|
||||||
opts = append(opts, edgeconnect.WithLogger(logger))
|
|
||||||
}
|
|
||||||
|
|
||||||
if username != "" && password != "" {
|
|
||||||
return edgeconnect.NewClientWithCredentials(baseURL, username, password, opts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to no auth for now - in production should require auth
|
|
||||||
return edgeconnect.NewClient(baseURL, opts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSDKClientV2() *v2.Client {
|
|
||||||
baseURL := viper.GetString("base_url")
|
|
||||||
username := viper.GetString("username")
|
|
||||||
password := viper.GetString("password")
|
|
||||||
|
|
||||||
err := validateBaseURL(baseURL)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error parsing baseURL: '%s' with error: %s\n", baseURL, err.Error())
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build options
|
|
||||||
opts := []v2.Option{
|
|
||||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add logger only if debug flag is set
|
|
||||||
if debug {
|
|
||||||
logger := log.New(os.Stderr, "[DEBUG] ", log.LstdFlags)
|
|
||||||
opts = append(opts, v2.WithLogger(logger))
|
|
||||||
}
|
|
||||||
|
|
||||||
if username != "" && password != "" {
|
|
||||||
return v2.NewClientWithCredentials(baseURL, username, password, opts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to no auth for now - in production should require auth
|
|
||||||
return v2.NewClient(baseURL, opts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
var appCmd = &cobra.Command{
|
|
||||||
Use: "app",
|
|
||||||
Short: "Manage Edge Connect applications",
|
|
||||||
Long: `Create, show, list, and delete Edge Connect applications.`,
|
|
||||||
}
|
|
||||||
|
|
||||||
var createAppCmd = &cobra.Command{
|
|
||||||
Use: "create",
|
|
||||||
Short: "Create a new Edge Connect application",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
apiVersion := getAPIVersion()
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if apiVersion == "v1" {
|
|
||||||
c := newSDKClientV1()
|
|
||||||
input := &edgeconnect.NewAppInput{
|
|
||||||
Region: region,
|
|
||||||
App: edgeconnect.App{
|
|
||||||
Key: edgeconnect.AppKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: appName,
|
|
||||||
Version: appVersion,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
err = c.CreateApp(context.Background(), input)
|
|
||||||
} else {
|
|
||||||
c := newSDKClientV2()
|
|
||||||
input := &v2.NewAppInput{
|
|
||||||
Region: region,
|
|
||||||
App: v2.App{
|
|
||||||
Key: v2.AppKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: appName,
|
|
||||||
Version: appVersion,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
err = c.CreateApp(context.Background(), input)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error creating app: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Application created successfully")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var showAppCmd = &cobra.Command{
|
|
||||||
Use: "show",
|
|
||||||
Short: "Show details of an Edge Connect application",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
apiVersion := getAPIVersion()
|
|
||||||
|
|
||||||
if apiVersion == "v1" {
|
|
||||||
c := newSDKClientV1()
|
|
||||||
appKey := edgeconnect.AppKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: appName,
|
|
||||||
Version: appVersion,
|
|
||||||
}
|
|
||||||
app, err := c.ShowApp(context.Background(), appKey, region)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error showing app: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("Application details:\n%+v\n", app)
|
|
||||||
} else {
|
|
||||||
c := newSDKClientV2()
|
|
||||||
appKey := v2.AppKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: appName,
|
|
||||||
Version: appVersion,
|
|
||||||
}
|
|
||||||
app, err := c.ShowApp(context.Background(), appKey, region)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error showing app: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("Application details:\n%+v\n", app)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var listAppsCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List Edge Connect applications",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
apiVersion := getAPIVersion()
|
|
||||||
|
|
||||||
if apiVersion == "v1" {
|
|
||||||
c := newSDKClientV1()
|
|
||||||
appKey := edgeconnect.AppKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: appName,
|
|
||||||
Version: appVersion,
|
|
||||||
}
|
|
||||||
apps, err := c.ShowApps(context.Background(), appKey, region)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error listing apps: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Applications:")
|
|
||||||
for _, app := range apps {
|
|
||||||
fmt.Printf("%+v\n", app)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
c := newSDKClientV2()
|
|
||||||
appKey := v2.AppKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: appName,
|
|
||||||
Version: appVersion,
|
|
||||||
}
|
|
||||||
apps, err := c.ShowApps(context.Background(), appKey, region)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error listing apps: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Applications:")
|
|
||||||
for _, app := range apps {
|
|
||||||
fmt.Printf("%+v\n", app)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var deleteAppCmd = &cobra.Command{
|
|
||||||
Use: "delete",
|
|
||||||
Short: "Delete an Edge Connect application",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
apiVersion := getAPIVersion()
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if apiVersion == "v1" {
|
|
||||||
c := newSDKClientV1()
|
|
||||||
appKey := edgeconnect.AppKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: appName,
|
|
||||||
Version: appVersion,
|
|
||||||
}
|
|
||||||
err = c.DeleteApp(context.Background(), appKey, region)
|
|
||||||
} else {
|
|
||||||
c := newSDKClientV2()
|
|
||||||
appKey := v2.AppKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: appName,
|
|
||||||
Version: appVersion,
|
|
||||||
}
|
|
||||||
err = c.DeleteApp(context.Background(), appKey, region)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error deleting app: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Application deleted successfully")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(appCmd)
|
|
||||||
appCmd.AddCommand(createAppCmd, showAppCmd, listAppsCmd, deleteAppCmd)
|
|
||||||
|
|
||||||
// Add common flags to all app commands
|
|
||||||
appCmds := []*cobra.Command{createAppCmd, showAppCmd, listAppsCmd, deleteAppCmd}
|
|
||||||
for _, cmd := range appCmds {
|
|
||||||
cmd.Flags().StringVarP(&organization, "org", "o", "", "organization name (required)")
|
|
||||||
cmd.Flags().StringVarP(&appName, "name", "n", "", "application name")
|
|
||||||
cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
|
|
||||||
cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)")
|
|
||||||
if err := cmd.MarkFlagRequired("org"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := cmd.MarkFlagRequired("region"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add required name flag for specific commands
|
|
||||||
for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} {
|
|
||||||
if err := cmd.MarkFlagRequired("name"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
56
cmd/cli/main.go
Normal file
56
cmd/cli/main.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driving/cli"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/app"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/cloudlet"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/instance"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/organization"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Hexagonal Architecture Wiring
|
||||||
|
|
||||||
|
// 1. Infrastructure Layer: Create the low-level EdgeConnect client
|
||||||
|
baseURL := getEnvOrDefault("EDGE_CONNECT_BASE_URL", "https://console.mobiledgex.net")
|
||||||
|
username := os.Getenv("EDGE_CONNECT_USERNAME")
|
||||||
|
password := os.Getenv("EDGE_CONNECT_PASSWORD")
|
||||||
|
|
||||||
|
// Use a logger for the infrastructure client
|
||||||
|
logger := log.New(os.Stderr, "[edgeconnect-client] ", log.LstdFlags)
|
||||||
|
clientOpts := []edgeconnect_client.Option{
|
||||||
|
edgeconnect_client.WithLogger(logger),
|
||||||
|
}
|
||||||
|
|
||||||
|
var infraClient *edgeconnect_client.Client
|
||||||
|
if username != "" && password != "" {
|
||||||
|
infraClient = edgeconnect_client.NewClientWithCredentials(baseURL, username, password, clientOpts...)
|
||||||
|
} else {
|
||||||
|
infraClient = edgeconnect_client.NewClient(baseURL, clientOpts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Adapter Layer: Create the driven adapter, injecting the infrastructure client.
|
||||||
|
// This adapter implements the repository interfaces required by the application layer.
|
||||||
|
edgeConnectAdapter := edgeconnect.NewAdapter(infraClient)
|
||||||
|
|
||||||
|
// 3. Application Layer: Create services, injecting the adapter (which fulfills the repository port).
|
||||||
|
appService := app.NewService(edgeConnectAdapter)
|
||||||
|
instanceService := instance.NewService(edgeConnectAdapter)
|
||||||
|
cloudletService := cloudlet.NewService(edgeConnectAdapter)
|
||||||
|
organizationService := organization.NewService(edgeConnectAdapter)
|
||||||
|
|
||||||
|
// 4. Driving Adapter (Presentation Layer): Execute the CLI, injecting the application services.
|
||||||
|
cli.ExecuteWithServices(appService, instanceService, cloudletService, organizationService)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvOrDefault(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
296
cmd/delete.go
296
cmd/delete.go
|
|
@ -1,296 +0,0 @@
|
||||||
// ABOUTME: CLI command for deleting EdgeConnect applications from YAML configuration
|
|
||||||
// ABOUTME: Removes applications and their instances based on configuration file specification
|
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
deletev1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/delete/v1"
|
|
||||||
deletev2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/delete/v2"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
deleteConfigFile string
|
|
||||||
deleteDryRun bool
|
|
||||||
deleteAutoApprove bool
|
|
||||||
)
|
|
||||||
|
|
||||||
var deleteCmd = &cobra.Command{
|
|
||||||
Use: "delete",
|
|
||||||
Short: "Delete EdgeConnect applications from configuration files",
|
|
||||||
Long: `Delete EdgeConnect applications and their instances based on YAML configuration files.
|
|
||||||
This command reads a configuration file, finds matching resources, and deletes them.
|
|
||||||
Instances are always deleted before the application.`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
if deleteConfigFile == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n")
|
|
||||||
_ = cmd.Usage()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := runDelete(deleteConfigFile, deleteDryRun, deleteAutoApprove); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDelete(configPath string, isDryRun bool, autoApprove bool) error {
|
|
||||||
// Step 1: Validate and resolve config file path
|
|
||||||
absPath, err := filepath.Abs(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to resolve config file path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("configuration file not found: %s", absPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("📄 Loading configuration from: %s\n", absPath)
|
|
||||||
|
|
||||||
// Step 2: Parse and validate configuration
|
|
||||||
parser := config.NewParser()
|
|
||||||
cfg, _, err := parser.ParseFile(absPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse configuration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := parser.Validate(cfg); err != nil {
|
|
||||||
return fmt.Errorf("configuration validation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name)
|
|
||||||
|
|
||||||
// Step 3: Determine API version and create appropriate client
|
|
||||||
apiVersion := getAPIVersion()
|
|
||||||
|
|
||||||
// Step 4: Execute deletion based on API version
|
|
||||||
if apiVersion == "v1" {
|
|
||||||
return runDeleteV1(cfg, isDryRun, autoApprove)
|
|
||||||
}
|
|
||||||
return runDeleteV2(cfg, isDryRun, autoApprove)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDeleteV1(cfg *config.EdgeConnectConfig, isDryRun bool, autoApprove bool) error {
|
|
||||||
// Create v1 client
|
|
||||||
client := newSDKClientV1()
|
|
||||||
|
|
||||||
// Create deletion planner
|
|
||||||
planner := deletev1.NewPlanner(client)
|
|
||||||
|
|
||||||
// Generate deletion plan
|
|
||||||
fmt.Println("🔍 Analyzing current state and generating deletion plan...")
|
|
||||||
|
|
||||||
planOptions := deletev1.DefaultPlanOptions()
|
|
||||||
planOptions.DryRun = isDryRun
|
|
||||||
|
|
||||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to generate deletion plan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display plan summary
|
|
||||||
fmt.Println("\n📋 Deletion Plan:")
|
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
|
||||||
fmt.Println(result.Plan.Summary)
|
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
|
||||||
|
|
||||||
// Display warnings if any
|
|
||||||
if len(result.Warnings) > 0 {
|
|
||||||
fmt.Println("\n⚠️ Warnings:")
|
|
||||||
for _, warning := range result.Warnings {
|
|
||||||
fmt.Printf(" • %s\n", warning)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If dry-run, stop here
|
|
||||||
if isDryRun {
|
|
||||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there's anything to delete
|
|
||||||
if result.Plan.TotalActions == 0 {
|
|
||||||
fmt.Println("\n✅ No resources found to delete.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nThis will delete %d resource(s). Estimated time: %v\n",
|
|
||||||
result.Plan.TotalActions, result.Plan.EstimatedDuration)
|
|
||||||
|
|
||||||
if !autoApprove && !confirmDeletion() {
|
|
||||||
fmt.Println("Deletion cancelled.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute deletion
|
|
||||||
fmt.Println("\n🗑️ Starting deletion...")
|
|
||||||
|
|
||||||
manager := deletev1.NewResourceManager(client, deletev1.WithLogger(log.Default()))
|
|
||||||
deleteResult, err := manager.ExecuteDeletion(context.Background(), result.Plan)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("deletion failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display results
|
|
||||||
return displayDeletionResults(deleteResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDeleteV2(cfg *config.EdgeConnectConfig, isDryRun bool, autoApprove bool) error {
|
|
||||||
// Create v2 client
|
|
||||||
client := newSDKClientV2()
|
|
||||||
|
|
||||||
// Create deletion planner
|
|
||||||
planner := deletev2.NewPlanner(client)
|
|
||||||
|
|
||||||
// Generate deletion plan
|
|
||||||
fmt.Println("🔍 Analyzing current state and generating deletion plan...")
|
|
||||||
|
|
||||||
planOptions := deletev2.DefaultPlanOptions()
|
|
||||||
planOptions.DryRun = isDryRun
|
|
||||||
|
|
||||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to generate deletion plan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display plan summary
|
|
||||||
fmt.Println("\n📋 Deletion Plan:")
|
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
|
||||||
fmt.Println(result.Plan.Summary)
|
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
|
||||||
|
|
||||||
// Display warnings if any
|
|
||||||
if len(result.Warnings) > 0 {
|
|
||||||
fmt.Println("\n⚠️ Warnings:")
|
|
||||||
for _, warning := range result.Warnings {
|
|
||||||
fmt.Printf(" • %s\n", warning)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If dry-run, stop here
|
|
||||||
if isDryRun {
|
|
||||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there's anything to delete
|
|
||||||
if result.Plan.TotalActions == 0 {
|
|
||||||
fmt.Println("\n✅ No resources found to delete.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nThis will delete %d resource(s). Estimated time: %v\n",
|
|
||||||
result.Plan.TotalActions, result.Plan.EstimatedDuration)
|
|
||||||
|
|
||||||
if !autoApprove && !confirmDeletion() {
|
|
||||||
fmt.Println("Deletion cancelled.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute deletion
|
|
||||||
fmt.Println("\n🗑️ Starting deletion...")
|
|
||||||
|
|
||||||
manager := deletev2.NewResourceManager(client, deletev2.WithLogger(log.Default()))
|
|
||||||
deleteResult, err := manager.ExecuteDeletion(context.Background(), result.Plan)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("deletion failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display results
|
|
||||||
return displayDeletionResults(deleteResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayDeletionResults(result interface{}) error {
|
|
||||||
// Use type assertion to handle both v1 and v2 result types
|
|
||||||
switch r := result.(type) {
|
|
||||||
case *deletev1.DeletionResult:
|
|
||||||
return displayDeletionResultsV1(r)
|
|
||||||
case *deletev2.DeletionResult:
|
|
||||||
return displayDeletionResultsV2(r)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unknown deletion result type")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayDeletionResultsV1(deleteResult *deletev1.DeletionResult) error {
|
|
||||||
if deleteResult.Success {
|
|
||||||
fmt.Printf("\n✅ Deletion completed successfully in %v\n", deleteResult.Duration)
|
|
||||||
if len(deleteResult.CompletedActions) > 0 {
|
|
||||||
fmt.Println("\nDeleted resources:")
|
|
||||||
for _, action := range deleteResult.CompletedActions {
|
|
||||||
fmt.Printf(" ✅ %s %s\n", action.Type, action.Target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Printf("\n❌ Deletion failed after %v\n", deleteResult.Duration)
|
|
||||||
if deleteResult.Error != nil {
|
|
||||||
fmt.Printf("Error: %v\n", deleteResult.Error)
|
|
||||||
}
|
|
||||||
if len(deleteResult.FailedActions) > 0 {
|
|
||||||
fmt.Println("\nFailed actions:")
|
|
||||||
for _, action := range deleteResult.FailedActions {
|
|
||||||
fmt.Printf(" ❌ %s %s: %v\n", action.Type, action.Target, action.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("deletion failed with %d failed actions", len(deleteResult.FailedActions))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayDeletionResultsV2(deleteResult *deletev2.DeletionResult) error {
|
|
||||||
if deleteResult.Success {
|
|
||||||
fmt.Printf("\n✅ Deletion completed successfully in %v\n", deleteResult.Duration)
|
|
||||||
if len(deleteResult.CompletedActions) > 0 {
|
|
||||||
fmt.Println("\nDeleted resources:")
|
|
||||||
for _, action := range deleteResult.CompletedActions {
|
|
||||||
fmt.Printf(" ✅ %s %s\n", action.Type, action.Target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Printf("\n❌ Deletion failed after %v\n", deleteResult.Duration)
|
|
||||||
if deleteResult.Error != nil {
|
|
||||||
fmt.Printf("Error: %v\n", deleteResult.Error)
|
|
||||||
}
|
|
||||||
if len(deleteResult.FailedActions) > 0 {
|
|
||||||
fmt.Println("\nFailed actions:")
|
|
||||||
for _, action := range deleteResult.FailedActions {
|
|
||||||
fmt.Printf(" ❌ %s %s: %v\n", action.Type, action.Target, action.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("deletion failed with %d failed actions", len(deleteResult.FailedActions))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmDeletion() bool {
|
|
||||||
fmt.Print("Do you want to proceed with deletion? (yes/no): ")
|
|
||||||
var response string
|
|
||||||
_, _ = fmt.Scanln(&response)
|
|
||||||
|
|
||||||
switch response {
|
|
||||||
case "yes", "y", "YES", "Y":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(deleteCmd)
|
|
||||||
|
|
||||||
deleteCmd.Flags().StringVarP(&deleteConfigFile, "file", "f", "", "configuration file path (required)")
|
|
||||||
deleteCmd.Flags().BoolVar(&deleteDryRun, "dry-run", false, "preview deletion without actually deleting resources")
|
|
||||||
deleteCmd.Flags().BoolVar(&deleteAutoApprove, "auto-approve", false, "automatically approve the deletion plan")
|
|
||||||
|
|
||||||
if err := deleteCmd.MarkFlagRequired("file"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
266
cmd/instance.go
266
cmd/instance.go
|
|
@ -1,266 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
|
||||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
cloudletName string
|
|
||||||
cloudletOrg string
|
|
||||||
instanceName string
|
|
||||||
flavorName string
|
|
||||||
appId string
|
|
||||||
)
|
|
||||||
|
|
||||||
var appInstanceCmd = &cobra.Command{
|
|
||||||
Use: "instance",
|
|
||||||
Short: "Manage Edge Connect application instances",
|
|
||||||
Long: `Create, show, list, and delete Edge Connect application instances.`,
|
|
||||||
}
|
|
||||||
|
|
||||||
var createInstanceCmd = &cobra.Command{
|
|
||||||
Use: "create",
|
|
||||||
Short: "Create a new Edge Connect application instance",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
apiVersion := getAPIVersion()
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if apiVersion == "v1" {
|
|
||||||
c := newSDKClientV1()
|
|
||||||
input := &edgeconnect.NewAppInstanceInput{
|
|
||||||
Region: region,
|
|
||||||
AppInst: edgeconnect.AppInstance{
|
|
||||||
Key: edgeconnect.AppInstanceKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: instanceName,
|
|
||||||
CloudletKey: edgeconnect.CloudletKey{
|
|
||||||
Organization: cloudletOrg,
|
|
||||||
Name: cloudletName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppKey: edgeconnect.AppKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: appName,
|
|
||||||
Version: appVersion,
|
|
||||||
},
|
|
||||||
Flavor: edgeconnect.Flavor{
|
|
||||||
Name: flavorName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
err = c.CreateAppInstance(context.Background(), input)
|
|
||||||
} else {
|
|
||||||
c := newSDKClientV2()
|
|
||||||
input := &v2.NewAppInstanceInput{
|
|
||||||
Region: region,
|
|
||||||
AppInst: v2.AppInstance{
|
|
||||||
Key: v2.AppInstanceKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: instanceName,
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: cloudletOrg,
|
|
||||||
Name: cloudletName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppKey: v2.AppKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: appName,
|
|
||||||
Version: appVersion,
|
|
||||||
},
|
|
||||||
Flavor: v2.Flavor{
|
|
||||||
Name: flavorName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
err = c.CreateAppInstance(context.Background(), input)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error creating app instance: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Application instance created successfully")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var showInstanceCmd = &cobra.Command{
|
|
||||||
Use: "show",
|
|
||||||
Short: "Show details of an Edge Connect application instance",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
apiVersion := getAPIVersion()
|
|
||||||
|
|
||||||
if apiVersion == "v1" {
|
|
||||||
c := newSDKClientV1()
|
|
||||||
instanceKey := edgeconnect.AppInstanceKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: instanceName,
|
|
||||||
CloudletKey: edgeconnect.CloudletKey{
|
|
||||||
Organization: cloudletOrg,
|
|
||||||
Name: cloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
appkey := edgeconnect.AppKey{Name: appId}
|
|
||||||
instance, err := c.ShowAppInstance(context.Background(), instanceKey, appkey, region)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error showing app instance: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("Application instance details:\n%+v\n", instance)
|
|
||||||
} else {
|
|
||||||
c := newSDKClientV2()
|
|
||||||
instanceKey := v2.AppInstanceKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: instanceName,
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: cloudletOrg,
|
|
||||||
Name: cloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
appkey := v2.AppKey{Name: appId}
|
|
||||||
instance, err := c.ShowAppInstance(context.Background(), instanceKey, appkey, region)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error showing app instance: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("Application instance details:\n%+v\n", instance)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var listInstancesCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List Edge Connect application instances",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
apiVersion := getAPIVersion()
|
|
||||||
|
|
||||||
if apiVersion == "v1" {
|
|
||||||
c := newSDKClientV1()
|
|
||||||
instanceKey := edgeconnect.AppInstanceKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: instanceName,
|
|
||||||
CloudletKey: edgeconnect.CloudletKey{
|
|
||||||
Organization: cloudletOrg,
|
|
||||||
Name: cloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
appKey := edgeconnect.AppKey{Name: appId}
|
|
||||||
instances, err := c.ShowAppInstances(context.Background(), instanceKey, appKey, region)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error listing app instances: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Application instances:")
|
|
||||||
for _, instance := range instances {
|
|
||||||
fmt.Printf("%+v\n", instance)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
c := newSDKClientV2()
|
|
||||||
instanceKey := v2.AppInstanceKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: instanceName,
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: cloudletOrg,
|
|
||||||
Name: cloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
appKey := v2.AppKey{Name: appId}
|
|
||||||
instances, err := c.ShowAppInstances(context.Background(), instanceKey, appKey, region)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error listing app instances: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Application instances:")
|
|
||||||
for _, instance := range instances {
|
|
||||||
fmt.Printf("%+v\n", instance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var deleteInstanceCmd = &cobra.Command{
|
|
||||||
Use: "delete",
|
|
||||||
Short: "Delete an Edge Connect application instance",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
apiVersion := getAPIVersion()
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if apiVersion == "v1" {
|
|
||||||
c := newSDKClientV1()
|
|
||||||
instanceKey := edgeconnect.AppInstanceKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: instanceName,
|
|
||||||
CloudletKey: edgeconnect.CloudletKey{
|
|
||||||
Organization: cloudletOrg,
|
|
||||||
Name: cloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
err = c.DeleteAppInstance(context.Background(), instanceKey, region)
|
|
||||||
} else {
|
|
||||||
c := newSDKClientV2()
|
|
||||||
instanceKey := v2.AppInstanceKey{
|
|
||||||
Organization: organization,
|
|
||||||
Name: instanceName,
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: cloudletOrg,
|
|
||||||
Name: cloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
err = c.DeleteAppInstance(context.Background(), instanceKey, region)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error deleting app instance: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Application instance deleted successfully")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(appInstanceCmd)
|
|
||||||
appInstanceCmd.AddCommand(createInstanceCmd, showInstanceCmd, listInstancesCmd, deleteInstanceCmd)
|
|
||||||
|
|
||||||
// Add flags to all instance commands
|
|
||||||
instanceCmds := []*cobra.Command{createInstanceCmd, showInstanceCmd, listInstancesCmd, deleteInstanceCmd}
|
|
||||||
for _, cmd := range instanceCmds {
|
|
||||||
cmd.Flags().StringVarP(&organization, "org", "o", "", "organization name (required)")
|
|
||||||
cmd.Flags().StringVarP(&instanceName, "name", "n", "", "instance name (required)")
|
|
||||||
cmd.Flags().StringVarP(&cloudletName, "cloudlet", "c", "", "cloudlet name (required)")
|
|
||||||
cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)")
|
|
||||||
cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)")
|
|
||||||
cmd.Flags().StringVarP(&appId, "app-id", "i", "", "application id")
|
|
||||||
|
|
||||||
if err := cmd.MarkFlagRequired("org"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := cmd.MarkFlagRequired("name"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := cmd.MarkFlagRequired("cloudlet"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := cmd.MarkFlagRequired("cloudlet-org"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := cmd.MarkFlagRequired("region"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add additional flags for create command
|
|
||||||
createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)")
|
|
||||||
createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
|
|
||||||
createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)")
|
|
||||||
if err := createInstanceCmd.MarkFlagRequired("app"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := createInstanceCmd.MarkFlagRequired("flavor"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
17
devbox.json
Normal file
17
devbox.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json",
|
||||||
|
"packages": [
|
||||||
|
"golangci-lint@latest",
|
||||||
|
"go@1.25"
|
||||||
|
],
|
||||||
|
"shell": {
|
||||||
|
"init_hook": [
|
||||||
|
"echo 'Welcome to devbox!' > /dev/null"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": [
|
||||||
|
"echo \"Error: no test specified\" && exit 1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
devbox.lock
Normal file
105
devbox.lock
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
{
|
||||||
|
"lockfile_version": "1",
|
||||||
|
"packages": {
|
||||||
|
"github:NixOS/nixpkgs/nixpkgs-unstable": {
|
||||||
|
"last_modified": "2025-10-07T08:41:47Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/bce5fe2bb998488d8e7e7856315f90496723793c?lastModified=1759826507&narHash=sha256-vwXL9H5zDHEQA0oFpww2one0%2FhkwnPAjc47LRph6d0I%3D"
|
||||||
|
},
|
||||||
|
"go@1.25": {
|
||||||
|
"last_modified": "2025-08-08T08:05:48Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/a3f3e3f2c983e957af6b07a1db98bafd1f87b7a1#go_1_25",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "1.25rc3",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/3h76v26b24dqxhd1i8gzcg8bwzxzmrhl-go-1.25rc3",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/3h76v26b24dqxhd1i8gzcg8bwzxzmrhl-go-1.25rc3"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/1sy3nfyahk3a3pg5x301jx96yxg8sw3y-go-1.25rc3",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/1sy3nfyahk3a3pg5x301jx96yxg8sw3y-go-1.25rc3"
|
||||||
|
},
|
||||||
|
"x86_64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/vh7db8clgyymv47wsddpw908bbf1dikm-go-1.25rc3",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/vh7db8clgyymv47wsddpw908bbf1dikm-go-1.25rc3"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/nbq04hs95dpiwmfnqiky5l4z8azbqj6i-go-1.25rc3",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/nbq04hs95dpiwmfnqiky5l4z8azbqj6i-go-1.25rc3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"golangci-lint@latest": {
|
||||||
|
"last_modified": "2025-09-18T16:33:27Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/f4b140d5b253f5e2a1ff4e5506edbf8267724bde#golangci-lint",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "2.4.0",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/2iiw320mwgw7flh47zbz6l62fakrb3dx-golangci-lint-2.4.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/2iiw320mwgw7flh47zbz6l62fakrb3dx-golangci-lint-2.4.0"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/hwr3wdhqnlcay07xpgv2wm1mx7k5nkhf-golangci-lint-2.4.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/hwr3wdhqnlcay07xpgv2wm1mx7k5nkhf-golangci-lint-2.4.0"
|
||||||
|
},
|
||||||
|
"x86_64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/skcc363l41rm6hjyrhzlfbk3rrwci2lb-golangci-lint-2.4.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/skcc363l41rm6hjyrhzlfbk3rrwci2lb-golangci-lint-2.4.0"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/dlz6z4dih7rd6q9dnigvz49npfmv8m52-golangci-lint-2.4.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/dlz6z4dih7rd6q9dnigvz49npfmv8m52-golangci-lint-2.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
outputs = inputs:
|
outputs = inputs:
|
||||||
let
|
let
|
||||||
goVersion = 25; # Change this to update the whole stack
|
goVersion = "1_25_1"; # Change this to update the whole stack
|
||||||
|
|
||||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||||
forEachSupportedSystem = f: inputs.nixpkgs.lib.genAttrs supportedSystems (system: f {
|
forEachSupportedSystem = f: inputs.nixpkgs.lib.genAttrs supportedSystems (system: f {
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
overlays.default = final: prev: {
|
overlays.default = final: prev: {
|
||||||
go = final."go_1_${toString goVersion}";
|
go = final."go_${goVersion}";
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells = forEachSupportedSystem ({ pkgs }: {
|
devShells = forEachSupportedSystem ({ pkgs }: {
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -1,4 +1,4 @@
|
||||||
module edp.buildth.ing/DevFW-CICD/edge-connect-client/v2
|
module edp.buildth.ing/DevFW-CICD/edge-connect-client
|
||||||
|
|
||||||
go 1.25.1
|
go 1.25.1
|
||||||
|
|
||||||
|
|
|
||||||
76
hexagonal-architecture-proposal.md
Normal file
76
hexagonal-architecture-proposal.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Proposal: Refactor to Hexagonal Architecture
|
||||||
|
|
||||||
|
This document proposes a refactoring of the `edge-connect-client` project to a Hexagonal Architecture (also known as Ports and Adapters). This will improve the project's maintainability, testability, and flexibility.
|
||||||
|
|
||||||
|
## Current Architecture
|
||||||
|
|
||||||
|
The current project structure is a mix of concerns. The `cmd` package contains both CLI handling and business logic, the `sdk` package is a client for the EdgeXR API, and the `internal` package contains some business logic and configuration handling. This makes it difficult to test the business logic in isolation and to adapt the application to different use cases.
|
||||||
|
|
||||||
|
## Proposed Hexagonal Architecture
|
||||||
|
|
||||||
|
The hexagonal architecture separates the application's core business logic from the outside world. The core communicates with the outside world through ports (interfaces), which are implemented by adapters.
|
||||||
|
|
||||||
|
Here is the proposed directory structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── cmd/
|
||||||
|
│ └── main.go
|
||||||
|
├── internal/
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── domain/
|
||||||
|
│ │ │ ├── app.go
|
||||||
|
│ │ │ └── instance.go
|
||||||
|
│ │ ├── ports/
|
||||||
|
│ │ │ ├── driven/
|
||||||
|
│ │ │ │ ├── app_repository.go
|
||||||
|
│ │ │ │ └── instance_repository.go
|
||||||
|
│ │ │ └── driving/
|
||||||
|
│ │ │ ├── app_service.go
|
||||||
|
│ │ │ └── instance_service.go
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ ├── app_service.go
|
||||||
|
│ │ └── instance_service.go
|
||||||
|
│ └── adapters/
|
||||||
|
│ ├── cli/
|
||||||
|
│ │ ├── app.go
|
||||||
|
│ │ └── instance.go
|
||||||
|
│ └── edgeconnect/
|
||||||
|
│ ├── app.go
|
||||||
|
│ └── instance.go
|
||||||
|
├── go.mod
|
||||||
|
└── go.sum
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
* `internal/core/domain`: Contains the core domain objects (e.g., `App`, `AppInstance`). These are plain Go structs with no external dependencies.
|
||||||
|
* `internal/core/ports`: Defines the interfaces for communication with the outside world.
|
||||||
|
* `driving`: Interfaces for the services offered by the application (e.g., `AppService`, `InstanceService`).
|
||||||
|
* `driven`: Interfaces for the services the application needs (e.g., `AppRepository`, `InstanceRepository`).
|
||||||
|
* `internal/core/services`: Implements the `driving` port interfaces. This is where the core business logic resides.
|
||||||
|
|
||||||
|
### Adapters
|
||||||
|
|
||||||
|
* `internal/adapters/driving/cli`: The CLI adapter. It implements the user interface and calls the `driving` ports of the core.
|
||||||
|
* `internal/adapters/driven/edgeconnect`: The EdgeXR API adapter. It implements the `driven` port interfaces and communicates with the EdgeXR API.
|
||||||
|
|
||||||
|
### `cmd`
|
||||||
|
|
||||||
|
* `cmd/cli/main.go`: The main entry point of the CLI application. It is responsible for wiring everything together: creating the adapters, injecting them into the core services, and starting the CLI.
|
||||||
|
|
||||||
|
## Refactoring Steps
|
||||||
|
|
||||||
|
1. **Define domain models:** Create the domain models in `internal/core/domain`.
|
||||||
|
2. **Define ports:** Define the `driving` and `driven` port interfaces in `internal/core/ports`.
|
||||||
|
3. **Implement core services:** Implement the core business logic in `internal/core/services`.
|
||||||
|
4. **Create adapters:**
|
||||||
|
* Move the existing CLI code from `cmd` to `internal/adapters/driving/cli` and adapt it to call the core services.
|
||||||
|
* Move the existing `sdk` code to `internal/adapters/driven/edgeconnect` and adapt it to implement the repository interfaces.
|
||||||
|
5. **Wire everything together:** Update `cmd/cli/main.go` to create the adapters and inject them into the core services.
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
* **Improved Testability:** The core business logic can be tested in isolation, without the need for the CLI framework or the EdgeXR API.
|
||||||
|
* **Increased Flexibility:** The application can be easily adapted to different use cases by creating new adapters. For example, we could add a REST API by creating a new adapter.
|
||||||
|
* **Better Separation of Concerns:** The hexagonal architecture enforces a clear separation between the business logic and the infrastructure, making the code easier to understand and maintain.
|
||||||
705
internal/adapters/driven/edgeconnect/adapter.go
Normal file
705
internal/adapters/driven/edgeconnect/adapter.go
Normal file
|
|
@ -0,0 +1,705 @@
|
||||||
|
package edgeconnect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Adapter implements the driven ports for the EdgeConnect API.
|
||||||
|
// It acts as a bridge between the application's core logic and the
|
||||||
|
// underlying infrastructure client, translating domain requests into
|
||||||
|
// infrastructure calls.
|
||||||
|
type Adapter struct {
|
||||||
|
client *edgeconnect_client.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdapter creates a new EdgeConnect adapter.
|
||||||
|
// It requires a configured infrastructure client to communicate with the API.
|
||||||
|
func NewAdapter(client *edgeconnect_client.Client) *Adapter {
|
||||||
|
return &Adapter{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the adapter implements all required repository interfaces.
|
||||||
|
var _ driven.AppRepository = (*Adapter)(nil)
|
||||||
|
var _ driven.AppInstanceRepository = (*Adapter)(nil)
|
||||||
|
var _ driven.CloudletRepository = (*Adapter)(nil)
|
||||||
|
var _ driven.OrganizationRepository = (*Adapter)(nil)
|
||||||
|
|
||||||
|
// OrganizationRepository implementation
|
||||||
|
|
||||||
|
// CreateOrganization creates a new organization.
|
||||||
|
func (a *Adapter) CreateOrganization(ctx context.Context, org *domain.Organization) error {
|
||||||
|
apiPath := "/api/v1/auth/org/create"
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, org, nil)
|
||||||
|
if err != nil {
|
||||||
|
// TODO: Improve error handling to return domain-specific errors
|
||||||
|
return fmt.Errorf("failed to create organization %s: %w", org.Name, err)
|
||||||
|
}
|
||||||
|
a.client.Logf("Successfully created organization: %s", org.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowOrganization retrieves a single organization by name.
|
||||||
|
func (a *Adapter) ShowOrganization(ctx context.Context, name string) (*domain.Organization, error) {
|
||||||
|
apiPath := "/api/v1/auth/org/show"
|
||||||
|
reqBody := map[string]string{"name": name}
|
||||||
|
var orgs []domain.Organization
|
||||||
|
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, reqBody, &orgs)
|
||||||
|
if err != nil {
|
||||||
|
// TODO: Improve error handling, check for 404 and return domain.ErrResourceNotFound
|
||||||
|
return nil, fmt.Errorf("failed to show organization %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(orgs) == 0 {
|
||||||
|
return nil, fmt.Errorf("organization '%s' not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(orgs) > 1 {
|
||||||
|
a.client.Logf("warning: ShowOrganization for '%s' returned %d results, expected 1", name, len(orgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &orgs[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOrganization updates an existing organization.
|
||||||
|
func (a *Adapter) UpdateOrganization(ctx context.Context, org *domain.Organization) error {
|
||||||
|
apiPath := "/api/v1/auth/org/update"
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, org, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update organization %s: %w", org.Name, err)
|
||||||
|
}
|
||||||
|
a.client.Logf("Successfully updated organization: %s", org.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrganization deletes an organization by name.
|
||||||
|
func (a *Adapter) DeleteOrganization(ctx context.Context, name string) error {
|
||||||
|
apiPath := "/api/v1/auth/org/delete"
|
||||||
|
reqBody := map[string]string{"name": name}
|
||||||
|
|
||||||
|
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, reqBody, nil)
|
||||||
|
if err != nil {
|
||||||
|
// A 404 status is acceptable, means it's already deleted.
|
||||||
|
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
a.client.Logf("Organization %s not found for deletion, considered successful.", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to delete organization %s: %w", name, err)
|
||||||
|
}
|
||||||
|
// The Call method now handles the response body closure if result is not nil.
|
||||||
|
// If result is nil, we must close it.
|
||||||
|
if resp != nil && resp.Body != nil {
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
a.client.Logf("Successfully deleted organization: %s", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppRepository implementation
|
||||||
|
|
||||||
|
// CreateApp creates a new application.
|
||||||
|
func (a *Adapter) CreateApp(ctx context.Context, region string, app *domain.App) error {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/CreateApp"
|
||||||
|
apiApp := toAPIApp(app)
|
||||||
|
input := &edgeconnect_client.NewAppInput{
|
||||||
|
Region: region,
|
||||||
|
App: *apiApp,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("CreateApp failed: %w", err)
|
||||||
|
}
|
||||||
|
a.client.Logf("CreateApp: %s/%s version %s created successfully",
|
||||||
|
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowApp retrieves a single application.
|
||||||
|
func (a *Adapter) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/ShowApp"
|
||||||
|
apiAppKey := toAPIAppKey(appKey)
|
||||||
|
filter := edgeconnect_client.AppFilter{
|
||||||
|
App: edgeconnect_client.App{Key: *apiAppKey},
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
var apps []edgeconnect_client.App
|
||||||
|
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, "application not found")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ShowApp failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := a.client.ParseStreamingResponse(resp, &apps); err != nil {
|
||||||
|
return nil, fmt.Errorf("ShowApp failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(apps) == 0 {
|
||||||
|
return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, "application not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
domainApp := toDomainApp(&apps[0])
|
||||||
|
return &domainApp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowApps retrieves all applications matching the filter.
|
||||||
|
func (a *Adapter) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/ShowApp"
|
||||||
|
apiAppKey := toAPIAppKey(appKey)
|
||||||
|
filter := edgeconnect_client.AppFilter{
|
||||||
|
App: edgeconnect_client.App{Key: *apiAppKey},
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiApps []edgeconnect_client.App
|
||||||
|
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
return []domain.App{}, nil // Return empty slice for not found
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ShowApps failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := a.client.ParseStreamingResponse(resp, &apiApps); err != nil {
|
||||||
|
return nil, fmt.Errorf("ShowApps failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.client.Logf("ShowApps: found %d apps matching criteria", len(apiApps))
|
||||||
|
domainApps := make([]domain.App, len(apiApps))
|
||||||
|
for i := range apiApps {
|
||||||
|
domainApps[i] = toDomainApp(&apiApps[i])
|
||||||
|
}
|
||||||
|
return domainApps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateApp updates an existing application.
|
||||||
|
func (a *Adapter) UpdateApp(ctx context.Context, region string, app *domain.App) error {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/UpdateApp"
|
||||||
|
apiApp := toAPIApp(app)
|
||||||
|
input := &edgeconnect_client.UpdateAppInput{
|
||||||
|
Region: region,
|
||||||
|
App: *apiApp,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("UpdateApp failed: %w", err)
|
||||||
|
}
|
||||||
|
a.client.Logf("UpdateApp: %s/%s version %s updated successfully",
|
||||||
|
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteApp deletes an application.
|
||||||
|
func (a *Adapter) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/DeleteApp"
|
||||||
|
apiAppKey := toAPIAppKey(appKey)
|
||||||
|
filter := edgeconnect_client.AppFilter{
|
||||||
|
App: edgeconnect_client.App{Key: *apiAppKey},
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
a.client.Logf("App %v not found for deletion, considered successful.", appKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("DeleteApp failed: %w", err)
|
||||||
|
}
|
||||||
|
a.client.Logf("DeleteApp: %s/%s version %s deleted successfully",
|
||||||
|
appKey.Organization, appKey.Name, appKey.Version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppInstanceRepository implementation
|
||||||
|
|
||||||
|
// CreateAppInstance creates a new application instance.
|
||||||
|
func (a *Adapter) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/CreateAppInst"
|
||||||
|
apiAppInst := toAPIAppInstance(appInst)
|
||||||
|
input := &edgeconnect_client.NewAppInstanceInput{
|
||||||
|
Region: region,
|
||||||
|
AppInst: *apiAppInst,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("CreateAppInstance failed: %w", err)
|
||||||
|
}
|
||||||
|
a.client.Logf("CreateAppInstance: %s/%s created successfully",
|
||||||
|
input.AppInst.Key.Organization, input.AppInst.Key.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowAppInstance retrieves a single application instance.
|
||||||
|
func (a *Adapter) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/ShowAppInst"
|
||||||
|
apiAppInstKey := toAPIAppInstanceKey(appInstKey)
|
||||||
|
filter := edgeconnect_client.AppInstanceFilter{
|
||||||
|
AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey},
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
var appInstances []edgeconnect_client.AppInstance
|
||||||
|
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, "app instance not found")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ShowAppInstance failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := a.client.ParseStreamingAppInstanceResponse(resp, &appInstances); err != nil {
|
||||||
|
return nil, fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(appInstances) == 0 {
|
||||||
|
return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, "app instance not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
domainAppInst := toDomainAppInstance(&appInstances[0])
|
||||||
|
return &domainAppInst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowAppInstances retrieves all application instances matching the filter.
|
||||||
|
func (a *Adapter) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/ShowAppInst"
|
||||||
|
apiAppInstKey := toAPIAppInstanceKey(appInstKey)
|
||||||
|
filter := edgeconnect_client.AppInstanceFilter{
|
||||||
|
AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey},
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
var appInstances []edgeconnect_client.AppInstance
|
||||||
|
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
return []domain.AppInstance{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := a.client.ParseStreamingAppInstanceResponse(resp, &appInstances); err != nil {
|
||||||
|
return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.client.Logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances))
|
||||||
|
domainAppInsts := make([]domain.AppInstance, len(appInstances))
|
||||||
|
for i := range appInstances {
|
||||||
|
domainAppInsts[i] = toDomainAppInstance(&appInstances[i])
|
||||||
|
}
|
||||||
|
return domainAppInsts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAppInstance updates an existing application instance.
|
||||||
|
func (a *Adapter) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/UpdateAppInst"
|
||||||
|
apiAppInst := toAPIAppInstance(appInst)
|
||||||
|
input := &edgeconnect_client.UpdateAppInstanceInput{
|
||||||
|
Region: region,
|
||||||
|
AppInst: *apiAppInst,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("UpdateAppInstance failed: %w", err)
|
||||||
|
}
|
||||||
|
a.client.Logf("UpdateAppInstance: %s/%s updated successfully",
|
||||||
|
input.AppInst.Key.Organization, input.AppInst.Key.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAppInstance refreshes an application instance.
|
||||||
|
func (a *Adapter) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/RefreshAppInst"
|
||||||
|
apiAppInstKey := toAPIAppInstanceKey(appInstKey)
|
||||||
|
filter := edgeconnect_client.AppInstanceFilter{
|
||||||
|
AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey},
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("RefreshAppInstance failed: %w", err)
|
||||||
|
}
|
||||||
|
a.client.Logf("RefreshAppInstance: %s/%s refreshed successfully",
|
||||||
|
appInstKey.Organization, appInstKey.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAppInstance deletes an application instance.
|
||||||
|
func (a *Adapter) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/DeleteAppInst"
|
||||||
|
apiAppInstKey := toAPIAppInstanceKey(appInstKey)
|
||||||
|
filter := edgeconnect_client.AppInstanceFilter{
|
||||||
|
AppInstance: edgeconnect_client.AppInstance{Key: *apiAppInstKey},
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
a.client.Logf("AppInstance %v not found for deletion, considered successful.", appInstKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("DeleteAppInstance failed: %w", err)
|
||||||
|
}
|
||||||
|
a.client.Logf("DeleteAppInstance: %s/%s deleted successfully",
|
||||||
|
appInstKey.Organization, appInstKey.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloudletRepository implementation
|
||||||
|
|
||||||
|
// CreateCloudlet creates a new cloudlet.
|
||||||
|
func (a *Adapter) CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/CreateCloudlet"
|
||||||
|
apiCloudlet := toAPICloudlet(cloudlet)
|
||||||
|
input := &edgeconnect_client.NewCloudletInput{
|
||||||
|
Region: region,
|
||||||
|
Cloudlet: *apiCloudlet,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, input, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("CreateCloudlet failed: %w", err)
|
||||||
|
}
|
||||||
|
a.client.Logf("CreateCloudlet: %s/%s created successfully",
|
||||||
|
input.Cloudlet.Key.Organization, input.Cloudlet.Key.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowCloudlet retrieves a single cloudlet.
|
||||||
|
func (a *Adapter) ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/ShowCloudlet"
|
||||||
|
apiCloudletKey := toAPICloudletKey(cloudletKey)
|
||||||
|
filter := edgeconnect_client.CloudletFilter{
|
||||||
|
Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey},
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
var cloudlets []edgeconnect_client.Cloudlet
|
||||||
|
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, "cloudlet not found")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ShowCloudlet failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := a.client.ParseStreamingCloudletResponse(resp, &cloudlets); err != nil {
|
||||||
|
return nil, fmt.Errorf("ShowCloudlet failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cloudlets) == 0 {
|
||||||
|
return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, "cloudlet not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
domainCloudlet := toDomainCloudlet(&cloudlets[0])
|
||||||
|
return &domainCloudlet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowCloudlets retrieves all cloudlets matching the filter.
|
||||||
|
func (a *Adapter) ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/ShowCloudlet"
|
||||||
|
apiCloudletKey := toAPICloudletKey(cloudletKey)
|
||||||
|
filter := edgeconnect_client.CloudletFilter{
|
||||||
|
Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey},
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
var cloudlets []edgeconnect_client.Cloudlet
|
||||||
|
resp, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
return []domain.Cloudlet{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ShowCloudlets failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := a.client.ParseStreamingCloudletResponse(resp, &cloudlets); err != nil {
|
||||||
|
return nil, fmt.Errorf("ShowCloudlets failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.client.Logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets))
|
||||||
|
domainCloudlets := make([]domain.Cloudlet, len(cloudlets))
|
||||||
|
for i := range cloudlets {
|
||||||
|
domainCloudlets[i] = toDomainCloudlet(&cloudlets[i])
|
||||||
|
}
|
||||||
|
return domainCloudlets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCloudlet deletes a cloudlet.
|
||||||
|
func (a *Adapter) DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/DeleteCloudlet"
|
||||||
|
apiCloudletKey := toAPICloudletKey(cloudletKey)
|
||||||
|
filter := edgeconnect_client.CloudletFilter{
|
||||||
|
Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey},
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, nil)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
a.client.Logf("Cloudlet %v not found for deletion, considered successful.", cloudletKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("DeleteCloudlet failed: %w", err)
|
||||||
|
}
|
||||||
|
a.client.Logf("DeleteCloudlet: %s/%s deleted successfully",
|
||||||
|
cloudletKey.Organization, cloudletKey.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCloudletManifest retrieves the deployment manifest for a cloudlet.
|
||||||
|
func (a *Adapter) GetCloudletManifest(ctx context.Context, cloudletKey domain.CloudletKey, region string) (*edgeconnect_client.CloudletManifest, error) {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/GetCloudletManifest"
|
||||||
|
apiCloudletKey := toAPICloudletKey(cloudletKey)
|
||||||
|
filter := edgeconnect_client.CloudletFilter{
|
||||||
|
Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey},
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest edgeconnect_client.CloudletManifest
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, &manifest)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "GetCloudletManifest", cloudletKey, region, "cloudlet manifest not found")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("GetCloudletManifest failed: %w", err)
|
||||||
|
}
|
||||||
|
a.client.Logf("GetCloudletManifest: retrieved manifest for %s/%s",
|
||||||
|
cloudletKey.Organization, cloudletKey.Name)
|
||||||
|
return &manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCloudletResourceUsage retrieves resource usage for a cloudlet.
|
||||||
|
func (a *Adapter) GetCloudletResourceUsage(ctx context.Context, cloudletKey domain.CloudletKey, region string) (*edgeconnect_client.CloudletResourceUsage, error) {
|
||||||
|
apiPath := "/api/v1/auth/ctrl/GetCloudletResourceUsage"
|
||||||
|
apiCloudletKey := toAPICloudletKey(cloudletKey)
|
||||||
|
filter := edgeconnect_client.CloudletFilter{
|
||||||
|
Cloudlet: edgeconnect_client.Cloudlet{Key: apiCloudletKey},
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
|
||||||
|
var usage edgeconnect_client.CloudletResourceUsage
|
||||||
|
_, err := a.client.Call(ctx, http.MethodPost, apiPath, filter, &usage)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*edgeconnect_client.APIError); ok && apiErr.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "GetCloudletResourceUsage", cloudletKey, region, "cloudlet resource usage not found")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err)
|
||||||
|
}
|
||||||
|
a.client.Logf("GetCloudletResourceUsage: retrieved usage for %s/%s",
|
||||||
|
cloudletKey.Organization, cloudletKey.Name)
|
||||||
|
return &usage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data mapping functions (domain <-> API)
|
||||||
|
|
||||||
|
func toAPIApp(app *domain.App) *edgeconnect_client.App {
|
||||||
|
return &edgeconnect_client.App{
|
||||||
|
Key: *toAPIAppKey(app.Key),
|
||||||
|
Deployment: app.Deployment,
|
||||||
|
ImageType: app.ImageType,
|
||||||
|
ImagePath: app.ImagePath,
|
||||||
|
AllowServerless: app.AllowServerless,
|
||||||
|
DefaultFlavor: toAPIFlavor(app.DefaultFlavor),
|
||||||
|
ServerlessConfig: app.ServerlessConfig,
|
||||||
|
DeploymentGenerator: app.DeploymentGenerator,
|
||||||
|
DeploymentManifest: app.DeploymentManifest,
|
||||||
|
RequiredOutboundConnections: toAPISecurityRules(app.RequiredOutboundConnections),
|
||||||
|
Fields: app.Fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainApp(app *edgeconnect_client.App) domain.App {
|
||||||
|
return domain.App{
|
||||||
|
Key: toDomainAppKey(app.Key),
|
||||||
|
Deployment: app.Deployment,
|
||||||
|
ImageType: app.ImageType,
|
||||||
|
ImagePath: app.ImagePath,
|
||||||
|
AllowServerless: app.AllowServerless,
|
||||||
|
DefaultFlavor: toDomainFlavor(app.DefaultFlavor),
|
||||||
|
ServerlessConfig: app.ServerlessConfig,
|
||||||
|
DeploymentGenerator: app.DeploymentGenerator,
|
||||||
|
DeploymentManifest: app.DeploymentManifest,
|
||||||
|
RequiredOutboundConnections: toDomainSecurityRules(app.RequiredOutboundConnections),
|
||||||
|
Fields: app.Fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAPIAppKey(appKey domain.AppKey) *edgeconnect_client.AppKey {
|
||||||
|
return &edgeconnect_client.AppKey{
|
||||||
|
Organization: appKey.Organization,
|
||||||
|
Name: appKey.Name,
|
||||||
|
Version: appKey.Version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainAppKey(appKey edgeconnect_client.AppKey) domain.AppKey {
|
||||||
|
return domain.AppKey{
|
||||||
|
Organization: appKey.Organization,
|
||||||
|
Name: appKey.Name,
|
||||||
|
Version: appKey.Version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAPIFlavor(flavor domain.Flavor) edgeconnect_client.Flavor {
|
||||||
|
return edgeconnect_client.Flavor{Name: flavor.Name}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainFlavor(flavor edgeconnect_client.Flavor) domain.Flavor {
|
||||||
|
return domain.Flavor{Name: flavor.Name}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAPISecurityRules(rules []domain.SecurityRule) []edgeconnect_client.SecurityRule {
|
||||||
|
apiRules := make([]edgeconnect_client.SecurityRule, len(rules))
|
||||||
|
for i, r := range rules {
|
||||||
|
apiRules[i] = edgeconnect_client.SecurityRule{
|
||||||
|
PortRangeMax: r.PortRangeMax,
|
||||||
|
PortRangeMin: r.PortRangeMin,
|
||||||
|
Protocol: r.Protocol,
|
||||||
|
RemoteCIDR: r.RemoteCIDR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return apiRules
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainSecurityRules(rules []edgeconnect_client.SecurityRule) []domain.SecurityRule {
|
||||||
|
domainRules := make([]domain.SecurityRule, len(rules))
|
||||||
|
for i, r := range rules {
|
||||||
|
domainRules[i] = domain.SecurityRule{
|
||||||
|
PortRangeMax: r.PortRangeMax,
|
||||||
|
PortRangeMin: r.PortRangeMin,
|
||||||
|
Protocol: r.Protocol,
|
||||||
|
RemoteCIDR: r.RemoteCIDR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domainRules
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAPIAppInstance(appInst *domain.AppInstance) *edgeconnect_client.AppInstance {
|
||||||
|
return &edgeconnect_client.AppInstance{
|
||||||
|
Key: *toAPIAppInstanceKey(appInst.Key),
|
||||||
|
AppKey: *toAPIAppKey(appInst.AppKey),
|
||||||
|
Flavor: toAPIFlavor(appInst.Flavor),
|
||||||
|
State: appInst.State,
|
||||||
|
PowerState: appInst.PowerState,
|
||||||
|
Fields: appInst.Fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainAppInstance(appInst *edgeconnect_client.AppInstance) domain.AppInstance {
|
||||||
|
return domain.AppInstance{
|
||||||
|
Key: toDomainAppInstanceKey(appInst.Key),
|
||||||
|
AppKey: toDomainAppKey(appInst.AppKey),
|
||||||
|
Flavor: toDomainFlavor(appInst.Flavor),
|
||||||
|
State: appInst.State,
|
||||||
|
PowerState: appInst.PowerState,
|
||||||
|
Fields: appInst.Fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAPIAppInstanceKey(key domain.AppInstanceKey) *edgeconnect_client.AppInstanceKey {
|
||||||
|
return &edgeconnect_client.AppInstanceKey{
|
||||||
|
Organization: key.Organization,
|
||||||
|
Name: key.Name,
|
||||||
|
CloudletKey: toAPICloudletKey(key.CloudletKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainAppInstanceKey(key edgeconnect_client.AppInstanceKey) domain.AppInstanceKey {
|
||||||
|
return domain.AppInstanceKey{
|
||||||
|
Organization: key.Organization,
|
||||||
|
Name: key.Name,
|
||||||
|
CloudletKey: toDomainCloudletKey(key.CloudletKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAPICloudletKey(key domain.CloudletKey) edgeconnect_client.CloudletKey {
|
||||||
|
return edgeconnect_client.CloudletKey{
|
||||||
|
Organization: key.Organization,
|
||||||
|
Name: key.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainCloudletKey(key edgeconnect_client.CloudletKey) domain.CloudletKey {
|
||||||
|
return domain.CloudletKey{
|
||||||
|
Organization: key.Organization,
|
||||||
|
Name: key.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAPICloudlet(cloudlet *domain.Cloudlet) *edgeconnect_client.Cloudlet {
|
||||||
|
return &edgeconnect_client.Cloudlet{
|
||||||
|
Key: toAPICloudletKey(cloudlet.Key),
|
||||||
|
Location: toAPILocation(cloudlet.Location),
|
||||||
|
IpSupport: cloudlet.IpSupport,
|
||||||
|
NumDynamicIps: cloudlet.NumDynamicIps,
|
||||||
|
State: cloudlet.State,
|
||||||
|
Flavor: toAPIFlavor(cloudlet.Flavor),
|
||||||
|
PhysicalName: cloudlet.PhysicalName,
|
||||||
|
Region: cloudlet.Region,
|
||||||
|
NotifySrvAddr: cloudlet.NotifySrvAddr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainCloudlet(cloudlet *edgeconnect_client.Cloudlet) domain.Cloudlet {
|
||||||
|
return domain.Cloudlet{
|
||||||
|
Key: toDomainCloudletKey(cloudlet.Key),
|
||||||
|
Location: toDomainLocation(cloudlet.Location),
|
||||||
|
IpSupport: cloudlet.IpSupport,
|
||||||
|
NumDynamicIps: cloudlet.NumDynamicIps,
|
||||||
|
State: cloudlet.State,
|
||||||
|
Flavor: toDomainFlavor(cloudlet.Flavor),
|
||||||
|
PhysicalName: cloudlet.PhysicalName,
|
||||||
|
Region: cloudlet.Region,
|
||||||
|
NotifySrvAddr: cloudlet.NotifySrvAddr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAPILocation(location domain.Location) edgeconnect_client.Location {
|
||||||
|
return edgeconnect_client.Location{
|
||||||
|
Latitude: location.Latitude,
|
||||||
|
Longitude: location.Longitude,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainLocation(location edgeconnect_client.Location) domain.Location {
|
||||||
|
return domain.Location{
|
||||||
|
Latitude: location.Latitude,
|
||||||
|
Longitude: location.Longitude,
|
||||||
|
}
|
||||||
|
}
|
||||||
173
internal/adapters/driving/cli/app.go
Normal file
173
internal/adapters/driving/cli/app.go
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateBaseURL checks if the provided string is a valid base URL.
|
||||||
|
// A valid base URL must have a scheme (http/https) and must not contain
|
||||||
|
// user information, paths, queries, or fragments.
|
||||||
|
func validateBaseURL(rawURL string) error {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL format: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Scheme == "" {
|
||||||
|
return fmt.Errorf("URL must have a scheme (e.g., http, https)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.User != nil {
|
||||||
|
return fmt.Errorf("URL should not contain user information")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Path != "" && u.Path != "/" {
|
||||||
|
return fmt.Errorf("URL should not contain a path")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.RawQuery != "" {
|
||||||
|
return fmt.Errorf("URL should not contain a query string")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Fragment != "" {
|
||||||
|
return fmt.Errorf("URL should not contain a fragment")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
organization string
|
||||||
|
appName string
|
||||||
|
appVersion string
|
||||||
|
region string
|
||||||
|
)
|
||||||
|
|
||||||
|
var appCmd = &cobra.Command{
|
||||||
|
Use: "app",
|
||||||
|
Short: "Manage Edge Connect applications",
|
||||||
|
Long: `Create, show, list, and delete Edge Connect applications.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var createAppCmd = &cobra.Command{
|
||||||
|
Use: "create",
|
||||||
|
Short: "Create a new Edge Connect application",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
app := &domain.App{
|
||||||
|
Key: domain.AppKey{
|
||||||
|
Organization: organization,
|
||||||
|
Name: appName,
|
||||||
|
Version: appVersion,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := services.AppService.CreateApp(context.Background(), region, app)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error creating app: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Application created successfully")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var showAppCmd = &cobra.Command{
|
||||||
|
Use: "show",
|
||||||
|
Short: "Show details of an Edge Connect application",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
appKey := domain.AppKey{
|
||||||
|
Organization: organization,
|
||||||
|
Name: appName,
|
||||||
|
Version: appVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := services.AppService.ShowApp(context.Background(), region, appKey)
|
||||||
|
if err != nil {
|
||||||
|
// Handle domain-specific errors with appropriate user feedback
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
fmt.Printf("Application %s/%s (version %s) not found in region %s\n",
|
||||||
|
appKey.Organization, appKey.Name, appKey.Version, region)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if domain.IsValidationError(err) {
|
||||||
|
fmt.Printf("Validation error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Error showing app: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Application details:\n%+v\n", app)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var listAppsCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List Edge Connect applications",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
appKey := domain.AppKey{
|
||||||
|
Organization: organization,
|
||||||
|
Name: appName,
|
||||||
|
Version: appVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
apps, err := services.AppService.ShowApps(context.Background(), region, appKey)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error listing apps: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Applications:")
|
||||||
|
for _, app := range apps {
|
||||||
|
fmt.Printf("%+v\n", app)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteAppCmd = &cobra.Command{
|
||||||
|
Use: "delete",
|
||||||
|
Short: "Delete an Edge Connect application",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
appKey := domain.AppKey{
|
||||||
|
Organization: organization,
|
||||||
|
Name: appName,
|
||||||
|
Version: appVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := services.AppService.DeleteApp(context.Background(), region, appKey)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error deleting app: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Application deleted successfully")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(appCmd)
|
||||||
|
appCmd.AddCommand(createAppCmd, showAppCmd, listAppsCmd, deleteAppCmd)
|
||||||
|
|
||||||
|
// Add common flags to all app commands
|
||||||
|
appCmds := []*cobra.Command{createAppCmd, showAppCmd, listAppsCmd, deleteAppCmd}
|
||||||
|
for _, cmd := range appCmds {
|
||||||
|
cmd.Flags().StringVarP(&organization, "org", "o", "", "organization name (required)")
|
||||||
|
cmd.Flags().StringVarP(&appName, "name", "n", "", "application name")
|
||||||
|
cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
|
||||||
|
cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)")
|
||||||
|
if err := cmd.MarkFlagRequired("org"); err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to mark 'org' flag as required: %v", err))
|
||||||
|
}
|
||||||
|
if err := cmd.MarkFlagRequired("region"); err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to mark 'region' flag as required: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add required name flag for specific commands
|
||||||
|
for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} {
|
||||||
|
if err := cmd.MarkFlagRequired("name"); err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to mark 'name' flag as required: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
// ABOUTME: CLI command for declarative deployment of EdgeConnect applications from YAML configuration
|
// ABOUTME: CLI command for declarative deployment of EdgeConnect applications from YAML configuration
|
||||||
// ABOUTME: Integrates config parser, deployment planner, and resource manager for complete deployment workflow
|
// ABOUTME: Integrates config parser, deployment planner, and resource manager for complete deployment workflow
|
||||||
package cmd
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
applyv1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/apply/v1"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/driven/edgeconnect"
|
||||||
applyv2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/apply/v2"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/application/apply"
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/edgeconnect_client"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -31,7 +34,9 @@ the necessary changes to deploy your applications across multiple cloudlets.`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if configFile == "" {
|
if configFile == "" {
|
||||||
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n")
|
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n")
|
||||||
_ = cmd.Usage()
|
if err := cmd.Usage(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to display usage: %v\n", err)
|
||||||
|
}
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,27 +73,41 @@ func runApply(configPath string, isDryRun bool, autoApprove bool) error {
|
||||||
|
|
||||||
fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name)
|
fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name)
|
||||||
|
|
||||||
// Step 3: Determine API version and create appropriate client
|
// Step 3: Create EdgeConnect client
|
||||||
apiVersion := getAPIVersion()
|
baseURL := getEnvOrDefault("EDGE_CONNECT_BASE_URL", "https://hub.apps.edge.platform.mg3.mdb.osc.live")
|
||||||
|
token := getEnvOrDefault("EDGE_CONNECT_TOKEN", "")
|
||||||
|
username := getEnvOrDefault("EDGE_CONNECT_USERNAME", "")
|
||||||
|
password := getEnvOrDefault("EDGE_CONNECT_PASSWORD", "")
|
||||||
|
|
||||||
// Step 4-6: Execute deployment based on API version
|
var client *edgeconnect_client.Client
|
||||||
if apiVersion == "v1" {
|
|
||||||
return runApplyV1(cfg, manifestContent, isDryRun, autoApprove)
|
if token != "" {
|
||||||
|
fmt.Println("🔐 Using Bearer token authentication")
|
||||||
|
client = edgeconnect_client.NewClient(baseURL,
|
||||||
|
edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||||
|
edgeconnect_client.WithAuthProvider(edgeconnect_client.NewStaticTokenProvider(token)),
|
||||||
|
edgeconnect_client.WithLogger(log.Default()),
|
||||||
|
)
|
||||||
|
} else if username != "" && password != "" {
|
||||||
|
fmt.Println("🔐 Using username/password authentication")
|
||||||
|
client = edgeconnect_client.NewClientWithCredentials(baseURL, username, password,
|
||||||
|
edgeconnect_client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||||
|
edgeconnect_client.WithLogger(log.Default()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log.Fatal("Authentication required: Set either EDGE_CONNECT_TOKEN or both EDGE_CONNECT_USERNAME and EDGE_CONNECT_PASSWORD")
|
||||||
}
|
}
|
||||||
return runApplyV2(cfg, manifestContent, isDryRun, autoApprove)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error {
|
// Step 4: Create driven adapter
|
||||||
// Create v1 client
|
adapter := edgeconnect.NewAdapter(client)
|
||||||
client := newSDKClientV1()
|
|
||||||
|
|
||||||
// Create deployment planner
|
// Step 5: Create deployment planner
|
||||||
planner := applyv1.NewPlanner(client)
|
planner := apply.NewPlanner(adapter, adapter)
|
||||||
|
|
||||||
// Generate deployment plan
|
// Step 5: Generate deployment plan
|
||||||
fmt.Println("🔍 Analyzing current state and generating deployment plan...")
|
fmt.Println("🔍 Analyzing current state and generating deployment plan...")
|
||||||
|
|
||||||
planOptions := applyv1.DefaultPlanOptions()
|
planOptions := apply.DefaultPlanOptions()
|
||||||
planOptions.DryRun = isDryRun
|
planOptions.DryRun = isDryRun
|
||||||
|
|
||||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
||||||
|
|
@ -96,7 +115,7 @@ func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun
|
||||||
return fmt.Errorf("failed to generate deployment plan: %w", err)
|
return fmt.Errorf("failed to generate deployment plan: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display plan summary
|
// Step 6: Display plan summary
|
||||||
fmt.Println("\n📋 Deployment Plan:")
|
fmt.Println("\n📋 Deployment Plan:")
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
fmt.Println(strings.Repeat("=", 50))
|
||||||
fmt.Println(result.Plan.Summary)
|
fmt.Println(result.Plan.Summary)
|
||||||
|
|
@ -110,13 +129,13 @@ func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If dry-run, stop here
|
// Step 7: If dry-run, stop here
|
||||||
if isDryRun {
|
if isDryRun {
|
||||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm deployment
|
// Step 8: Confirm deployment (in non-dry-run mode)
|
||||||
if result.Plan.TotalActions == 0 {
|
if result.Plan.TotalActions == 0 {
|
||||||
fmt.Println("\n✅ No changes needed. Resources are already in desired state.")
|
fmt.Println("\n✅ No changes needed. Resources are already in desired state.")
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -130,98 +149,16 @@ func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute deployment
|
// Step 9: Execute deployment
|
||||||
fmt.Println("\n🚀 Starting deployment...")
|
fmt.Println("\n🚀 Starting deployment...")
|
||||||
|
|
||||||
manager := applyv1.NewResourceManager(client, applyv1.WithLogger(log.Default()))
|
manager := apply.NewResourceManager(adapter, adapter, apply.WithLogger(log.Default()))
|
||||||
deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
|
deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("deployment failed: %w", err)
|
return fmt.Errorf("deployment failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display results
|
// Step 10: Display results
|
||||||
return displayDeploymentResults(deployResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runApplyV2(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error {
|
|
||||||
// Create v2 client
|
|
||||||
client := newSDKClientV2()
|
|
||||||
|
|
||||||
// Create deployment planner
|
|
||||||
planner := applyv2.NewPlanner(client)
|
|
||||||
|
|
||||||
// Generate deployment plan
|
|
||||||
fmt.Println("🔍 Analyzing current state and generating deployment plan...")
|
|
||||||
|
|
||||||
planOptions := applyv2.DefaultPlanOptions()
|
|
||||||
planOptions.DryRun = isDryRun
|
|
||||||
|
|
||||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to generate deployment plan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display plan summary
|
|
||||||
fmt.Println("\n📋 Deployment Plan:")
|
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
|
||||||
fmt.Println(result.Plan.Summary)
|
|
||||||
fmt.Println(strings.Repeat("=", 50))
|
|
||||||
|
|
||||||
// Display warnings if any
|
|
||||||
if len(result.Warnings) > 0 {
|
|
||||||
fmt.Println("\n⚠️ Warnings:")
|
|
||||||
for _, warning := range result.Warnings {
|
|
||||||
fmt.Printf(" • %s\n", warning)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If dry-run, stop here
|
|
||||||
if isDryRun {
|
|
||||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm deployment
|
|
||||||
if result.Plan.TotalActions == 0 {
|
|
||||||
fmt.Println("\n✅ No changes needed. Resources are already in desired state.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nThis will perform %d actions. Estimated time: %v\n",
|
|
||||||
result.Plan.TotalActions, result.Plan.EstimatedDuration)
|
|
||||||
|
|
||||||
if !autoApprove && !confirmDeployment() {
|
|
||||||
fmt.Println("Deployment cancelled.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute deployment
|
|
||||||
fmt.Println("\n🚀 Starting deployment...")
|
|
||||||
|
|
||||||
manager := applyv2.NewResourceManager(client, applyv2.WithLogger(log.Default()))
|
|
||||||
deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("deployment failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display results
|
|
||||||
return displayDeploymentResults(deployResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayDeploymentResults(result interface{}) error {
|
|
||||||
// Use reflection or type assertion to handle both v1 and v2 result types
|
|
||||||
// For now, we'll use a simple approach that works with both
|
|
||||||
switch r := result.(type) {
|
|
||||||
case *applyv1.ExecutionResult:
|
|
||||||
return displayDeploymentResultsV1(r)
|
|
||||||
case *applyv2.ExecutionResult:
|
|
||||||
return displayDeploymentResultsV2(r)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unknown deployment result type")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayDeploymentResultsV1(deployResult *applyv1.ExecutionResult) error {
|
|
||||||
if deployResult.Success {
|
if deployResult.Success {
|
||||||
fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration)
|
fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration)
|
||||||
if len(deployResult.CompletedActions) > 0 {
|
if len(deployResult.CompletedActions) > 0 {
|
||||||
|
|
@ -243,38 +180,17 @@ func displayDeploymentResultsV1(deployResult *applyv1.ExecutionResult) error {
|
||||||
}
|
}
|
||||||
return fmt.Errorf("deployment failed with %d failed actions", len(deployResult.FailedActions))
|
return fmt.Errorf("deployment failed with %d failed actions", len(deployResult.FailedActions))
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayDeploymentResultsV2(deployResult *applyv2.ExecutionResult) error {
|
|
||||||
if deployResult.Success {
|
|
||||||
fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration)
|
|
||||||
if len(deployResult.CompletedActions) > 0 {
|
|
||||||
fmt.Println("\nCompleted actions:")
|
|
||||||
for _, action := range deployResult.CompletedActions {
|
|
||||||
fmt.Printf(" ✅ %s %s\n", action.Type, action.Target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Printf("\n❌ Deployment failed after %v\n", deployResult.Duration)
|
|
||||||
if deployResult.Error != nil {
|
|
||||||
fmt.Printf("Error: %v\n", deployResult.Error)
|
|
||||||
}
|
|
||||||
if len(deployResult.FailedActions) > 0 {
|
|
||||||
fmt.Println("\nFailed actions:")
|
|
||||||
for _, action := range deployResult.FailedActions {
|
|
||||||
fmt.Printf(" ❌ %s %s: %v\n", action.Type, action.Target, action.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("deployment failed with %d failed actions", len(deployResult.FailedActions))
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmDeployment() bool {
|
func confirmDeployment() bool {
|
||||||
fmt.Print("Do you want to proceed? (yes/no): ")
|
fmt.Print("Do you want to proceed? (yes/no): ")
|
||||||
var response string
|
var response string
|
||||||
_, _ = fmt.Scanln(&response)
|
if _, err := fmt.Scanln(&response); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to read input: %v\n", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
switch response {
|
switch response {
|
||||||
case "yes", "y", "YES", "Y":
|
case "yes", "y", "YES", "Y":
|
||||||
|
|
@ -284,6 +200,13 @@ func confirmDeployment() bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnvOrDefault(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(applyCmd)
|
rootCmd.AddCommand(applyCmd)
|
||||||
|
|
||||||
|
|
@ -292,6 +215,6 @@ func init() {
|
||||||
applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan")
|
applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan")
|
||||||
|
|
||||||
if err := applyCmd.MarkFlagRequired("file"); err != nil {
|
if err := applyCmd.MarkFlagRequired("file"); err != nil {
|
||||||
panic(err)
|
panic(fmt.Sprintf("Failed to mark 'file' flag as required: %v", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
156
internal/adapters/driving/cli/instance.go
Normal file
156
internal/adapters/driving/cli/instance.go
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cloudletName string
|
||||||
|
cloudletOrg string
|
||||||
|
instanceName string
|
||||||
|
flavorName string
|
||||||
|
)
|
||||||
|
|
||||||
|
var appInstanceCmd = &cobra.Command{
|
||||||
|
Use: "instance",
|
||||||
|
Short: "Manage Edge Connect application instances",
|
||||||
|
Long: `Create, show, list, and delete Edge Connect application instances.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var createInstanceCmd = &cobra.Command{
|
||||||
|
Use: "create",
|
||||||
|
Short: "Create a new Edge Connect application instance",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
appInst := &domain.AppInstance{
|
||||||
|
Key: domain.AppInstanceKey{
|
||||||
|
Organization: organization,
|
||||||
|
Name: instanceName,
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: cloudletOrg,
|
||||||
|
Name: cloudletName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AppKey: domain.AppKey{
|
||||||
|
Organization: organization,
|
||||||
|
Name: appName,
|
||||||
|
Version: appVersion,
|
||||||
|
},
|
||||||
|
Flavor: domain.Flavor{
|
||||||
|
Name: flavorName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := services.InstanceService.CreateAppInstance(context.Background(), region, appInst)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error creating app instance: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Application instance created successfully")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var showInstanceCmd = &cobra.Command{
|
||||||
|
Use: "show",
|
||||||
|
Short: "Show details of an Edge Connect application instance",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
instanceKey := domain.AppInstanceKey{
|
||||||
|
Organization: organization,
|
||||||
|
Name: instanceName,
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: cloudletOrg,
|
||||||
|
Name: cloudletName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
instance, err := services.InstanceService.ShowAppInstance(context.Background(), region, instanceKey)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error showing app instance: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Application instance details:\n%+v\n", instance)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var listInstancesCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List Edge Connect application instances",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
instanceKey := domain.AppInstanceKey{
|
||||||
|
Organization: organization,
|
||||||
|
Name: instanceName,
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: cloudletOrg,
|
||||||
|
Name: cloudletName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
instances, err := services.InstanceService.ShowAppInstances(context.Background(), region, instanceKey)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error listing app instances: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Application instances:")
|
||||||
|
for _, instance := range instances {
|
||||||
|
fmt.Printf("%+v\n", instance)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteInstanceCmd = &cobra.Command{
|
||||||
|
Use: "delete",
|
||||||
|
Short: "Delete an Edge Connect application instance",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
instanceKey := domain.AppInstanceKey{
|
||||||
|
Organization: organization,
|
||||||
|
Name: instanceName,
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: cloudletOrg,
|
||||||
|
Name: cloudletName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := services.InstanceService.DeleteAppInstance(context.Background(), region, instanceKey)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error deleting app instance: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("Application instance deleted successfully")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(appInstanceCmd)
|
||||||
|
appInstanceCmd.AddCommand(createInstanceCmd, showInstanceCmd, listInstancesCmd, deleteInstanceCmd)
|
||||||
|
|
||||||
|
// Add flags to all instance commands
|
||||||
|
instanceCmds := []*cobra.Command{createInstanceCmd, showInstanceCmd, listInstancesCmd, deleteInstanceCmd}
|
||||||
|
for _, cmd := range instanceCmds {
|
||||||
|
cmd.Flags().StringVarP(&organization, "org", "o", "", "organization name (required)")
|
||||||
|
cmd.Flags().StringVarP(&instanceName, "name", "n", "", "instance name (required)")
|
||||||
|
cmd.Flags().StringVarP(&cloudletName, "cloudlet", "c", "", "cloudlet name (required)")
|
||||||
|
cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)")
|
||||||
|
cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)")
|
||||||
|
|
||||||
|
for _, flag := range []string{"org", "name", "cloudlet", "cloudlet-org", "region"} {
|
||||||
|
if err := cmd.MarkFlagRequired(flag); err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to mark '%s' flag as required: %v", flag, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add additional flags for create command
|
||||||
|
createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)")
|
||||||
|
createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
|
||||||
|
createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)")
|
||||||
|
if err := createInstanceCmd.MarkFlagRequired("app"); err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to mark 'app' flag as required: %v", err))
|
||||||
|
}
|
||||||
|
if err := createInstanceCmd.MarkFlagRequired("flavor"); err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to mark 'flavor' flag as required: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
112
internal/adapters/driving/cli/organization.go
Normal file
112
internal/adapters/driving/cli/organization.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(organizationCmd)
|
||||||
|
organizationCmd.AddCommand(createOrganizationCmd, showOrganizationCmd, updateOrganizationCmd, deleteOrganizationCmd)
|
||||||
|
|
||||||
|
// Flags for create/update
|
||||||
|
createOrganizationCmd.Flags().StringVar(&orgAddress, "address", "", "Address of the organization")
|
||||||
|
createOrganizationCmd.Flags().StringVar(&orgPhone, "phone", "", "Phone number of the organization")
|
||||||
|
|
||||||
|
updateOrganizationCmd.Flags().StringVar(&orgAddress, "address", "", "New address for the organization")
|
||||||
|
updateOrganizationCmd.Flags().StringVar(&orgPhone, "phone", "", "New phone number for the organization")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
orgAddress string
|
||||||
|
orgPhone string
|
||||||
|
)
|
||||||
|
|
||||||
|
// organizationCmd represents the parent command for all organization-related actions.
|
||||||
|
var organizationCmd = &cobra.Command{
|
||||||
|
Use: "organization",
|
||||||
|
Short: "Manage organizations",
|
||||||
|
Long: `Create, show, update, and delete organizations in Edge Connect.`,
|
||||||
|
Aliases: []string{"org"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var createOrganizationCmd = &cobra.Command{
|
||||||
|
Use: "create [name]",
|
||||||
|
Short: "Create a new organization",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
org := &domain.Organization{
|
||||||
|
Name: args[0],
|
||||||
|
Address: orgAddress,
|
||||||
|
Phone: orgPhone,
|
||||||
|
}
|
||||||
|
err := services.OrganizationService.Create(context.Background(), org)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating organization: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Organization '%s' created successfully.\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var showOrganizationCmd = &cobra.Command{
|
||||||
|
Use: "show [name]",
|
||||||
|
Short: "Show details of an organization",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
org, err := services.OrganizationService.Get(context.Background(), args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error showing organization: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Organization Details:\n")
|
||||||
|
fmt.Printf(" Name: %s\n", org.Name)
|
||||||
|
fmt.Printf(" Address: %s\n", org.Address)
|
||||||
|
fmt.Printf(" Phone: %s\n", org.Phone)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateOrganizationCmd = &cobra.Command{
|
||||||
|
Use: "update [name]",
|
||||||
|
Short: "Update an organization",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// First, get the current organization to update
|
||||||
|
org, err := services.OrganizationService.Get(context.Background(), args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not retrieve organization to update: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields if flags were provided
|
||||||
|
if cmd.Flags().Changed("address") {
|
||||||
|
org.Address = orgAddress
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("phone") {
|
||||||
|
org.Phone = orgPhone
|
||||||
|
}
|
||||||
|
|
||||||
|
err = services.OrganizationService.Update(context.Background(), org)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error updating organization: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Organization '%s' updated successfully.\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteOrganizationCmd = &cobra.Command{
|
||||||
|
Use: "delete [name]",
|
||||||
|
Short: "Delete an organization",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
err := services.OrganizationService.Delete(context.Background(), args[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error deleting organization: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Organization '%s' deleted successfully.\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,32 @@
|
||||||
package cmd
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfgFile string
|
cfgFile string
|
||||||
baseURL string
|
baseURL string
|
||||||
username string
|
username string
|
||||||
password string
|
password string
|
||||||
debug bool
|
|
||||||
apiVersion string
|
// Services injected via constructor - no global state
|
||||||
|
services *ServiceContainer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ServiceContainer holds injected services (simple struct - no complex container)
|
||||||
|
type ServiceContainer struct {
|
||||||
|
AppService driving.AppService
|
||||||
|
InstanceService driving.AppInstanceService
|
||||||
|
CloudletService driving.CloudletService
|
||||||
|
OrganizationService driving.OrganizationService
|
||||||
|
}
|
||||||
|
|
||||||
// rootCmd represents the base command when called without any subcommands
|
// rootCmd represents the base command when called without any subcommands
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "edge-connect",
|
Use: "edge-connect",
|
||||||
|
|
@ -34,27 +44,37 @@ func Execute() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecuteWithServices executes CLI with dependency-injected services (simple parameter passing)
|
||||||
|
func ExecuteWithServices(appSvc driving.AppService, instanceSvc driving.AppInstanceService, cloudletSvc driving.CloudletService, orgSvc driving.OrganizationService) {
|
||||||
|
// Simple dependency injection - just store services in container
|
||||||
|
services = &ServiceContainer{
|
||||||
|
AppService: appSvc,
|
||||||
|
InstanceService: instanceSvc,
|
||||||
|
CloudletService: cloudletSvc,
|
||||||
|
OrganizationService: orgSvc,
|
||||||
|
}
|
||||||
|
|
||||||
|
Execute()
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize(initConfig)
|
cobra.OnInitialize(initConfig)
|
||||||
|
|
||||||
|
rootCmd.AddCommand(organizationCmd)
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.edge-connect.yaml)")
|
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.edge-connect.yaml)")
|
||||||
rootCmd.PersistentFlags().StringVar(&baseURL, "base-url", "", "base URL for the Edge Connect API")
|
rootCmd.PersistentFlags().StringVar(&baseURL, "base-url", "", "base URL for the Edge Connect API")
|
||||||
rootCmd.PersistentFlags().StringVar(&username, "username", "", "username for authentication")
|
rootCmd.PersistentFlags().StringVar(&username, "username", "", "username for authentication")
|
||||||
rootCmd.PersistentFlags().StringVar(&password, "password", "", "password for authentication")
|
rootCmd.PersistentFlags().StringVar(&password, "password", "", "password for authentication")
|
||||||
rootCmd.PersistentFlags().StringVar(&apiVersion, "api-version", "v2", "API version to use (v1 or v2)")
|
|
||||||
rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging")
|
|
||||||
|
|
||||||
if err := viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")); err != nil {
|
if err := viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")); err != nil {
|
||||||
panic(err)
|
panic(fmt.Sprintf("Failed to bind base-url flag: %v", err))
|
||||||
}
|
}
|
||||||
if err := viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")); err != nil {
|
if err := viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")); err != nil {
|
||||||
panic(err)
|
panic(fmt.Sprintf("Failed to bind username flag: %v", err))
|
||||||
}
|
}
|
||||||
if err := viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")); err != nil {
|
if err := viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")); err != nil {
|
||||||
panic(err)
|
panic(fmt.Sprintf("Failed to bind password flag: %v", err))
|
||||||
}
|
|
||||||
if err := viper.BindPFlag("api_version", rootCmd.PersistentFlags().Lookup("api-version")); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,16 +82,13 @@ func initConfig() {
|
||||||
viper.AutomaticEnv()
|
viper.AutomaticEnv()
|
||||||
viper.SetEnvPrefix("EDGE_CONNECT")
|
viper.SetEnvPrefix("EDGE_CONNECT")
|
||||||
if err := viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL"); err != nil {
|
if err := viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL"); err != nil {
|
||||||
panic(err)
|
panic(fmt.Sprintf("Failed to bind base_url environment variable: %v", err))
|
||||||
}
|
}
|
||||||
if err := viper.BindEnv("username", "EDGE_CONNECT_USERNAME"); err != nil {
|
if err := viper.BindEnv("username", "EDGE_CONNECT_USERNAME"); err != nil {
|
||||||
panic(err)
|
panic(fmt.Sprintf("Failed to bind username environment variable: %v", err))
|
||||||
}
|
}
|
||||||
if err := viper.BindEnv("password", "EDGE_CONNECT_PASSWORD"); err != nil {
|
if err := viper.BindEnv("password", "EDGE_CONNECT_PASSWORD"); err != nil {
|
||||||
panic(err)
|
panic(fmt.Sprintf("Failed to bind password environment variable: %v", err))
|
||||||
}
|
|
||||||
if err := viper.BindEnv("api_version", "EDGE_CONNECT_API_VERSION"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfgFile != "" {
|
if cfgFile != "" {
|
||||||
|
|
@ -91,4 +108,4 @@ func initConfig() {
|
||||||
if err := viper.ReadInConfig(); err == nil {
|
if err := viper.ReadInConfig(); err == nil {
|
||||||
fmt.Println("Using config file:", viper.ConfigFileUsed())
|
fmt.Println("Using config file:", viper.ConfigFileUsed())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
157
internal/application/app/service.go
Normal file
157
internal/application/app/service.go
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving"
|
||||||
|
)
|
||||||
|
|
||||||
|
type service struct {
|
||||||
|
appRepo driven.AppRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(appRepo driven.AppRepository) driving.AppService {
|
||||||
|
return &service{appRepo: appRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) CreateApp(ctx context.Context, region string, app *domain.App) error {
|
||||||
|
// Validate inputs before delegating to repository
|
||||||
|
if err := s.validateApp(app); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "" {
|
||||||
|
return domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.appRepo.CreateApp(ctx, region, app); err != nil {
|
||||||
|
// Map repository errors to domain errors with context
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
return domain.NewAppError(domain.ErrResourceConflict, "CreateApp", app.Key, region,
|
||||||
|
"app may already exist or have conflicting configuration")
|
||||||
|
}
|
||||||
|
return domain.NewAppError(domain.ErrInternalError, "CreateApp", app.Key, region,
|
||||||
|
"failed to create application").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) {
|
||||||
|
if err := s.validateAppKey(appKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "" {
|
||||||
|
return nil, domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := s.appRepo.ShowApp(ctx, region, appKey)
|
||||||
|
if err != nil {
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region,
|
||||||
|
"application does not exist")
|
||||||
|
}
|
||||||
|
return nil, domain.NewAppError(domain.ErrInternalError, "ShowApp", appKey, region,
|
||||||
|
"failed to retrieve application").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return app, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) {
|
||||||
|
if region == "" {
|
||||||
|
return nil, domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
apps, err := s.appRepo.ShowApps(ctx, region, appKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, domain.NewAppError(domain.ErrInternalError, "ShowApps", appKey, region,
|
||||||
|
"failed to list applications").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return apps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error {
|
||||||
|
if err := s.validateAppKey(appKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "" {
|
||||||
|
return domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.appRepo.DeleteApp(ctx, region, appKey); err != nil {
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
return domain.NewAppError(domain.ErrResourceNotFound, "DeleteApp", appKey, region,
|
||||||
|
"application does not exist")
|
||||||
|
}
|
||||||
|
return domain.NewAppError(domain.ErrInternalError, "DeleteApp", appKey, region,
|
||||||
|
"failed to delete application").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) UpdateApp(ctx context.Context, region string, app *domain.App) error {
|
||||||
|
if err := s.validateApp(app); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "" {
|
||||||
|
return domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.appRepo.UpdateApp(ctx, region, app); err != nil {
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
return domain.NewAppError(domain.ErrResourceNotFound, "UpdateApp", app.Key, region,
|
||||||
|
"application does not exist")
|
||||||
|
}
|
||||||
|
return domain.NewAppError(domain.ErrInternalError, "UpdateApp", app.Key, region,
|
||||||
|
"failed to update application").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateApp performs business logic validation on an app
|
||||||
|
func (s *service) validateApp(app *domain.App) error {
|
||||||
|
if app == nil {
|
||||||
|
return domain.NewDomainError(domain.ErrValidationFailed, "application cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.validateAppKey(app.Key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(app.ImagePath) == "" {
|
||||||
|
return domain.NewDomainError(domain.ErrValidationFailed, "image path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(app.Deployment) == "" {
|
||||||
|
return domain.NewDomainError(domain.ErrValidationFailed, "deployment type is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAppKey performs business logic validation on an app key
|
||||||
|
func (s *service) validateAppKey(appKey domain.AppKey) error {
|
||||||
|
if strings.TrimSpace(appKey.Organization) == "" {
|
||||||
|
return domain.ErrInvalidAppKey.WithDetails("organization is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(appKey.Name) == "" {
|
||||||
|
return domain.ErrInvalidAppKey.WithDetails("name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(appKey.Version) == "" {
|
||||||
|
return domain.ErrInvalidAppKey.WithDetails("version is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
// ABOUTME: Resource management for EdgeConnect apply command with deployment execution and rollback
|
// ABOUTME: Resource management for EdgeConnect apply command with deployment execution and rollback
|
||||||
// ABOUTME: Handles actual deployment operations, manifest processing, and error recovery with parallel execution
|
// ABOUTME: Handles actual deployment operations, manifest processing, and error recovery with parallel execution
|
||||||
package v1
|
package apply
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResourceManagerInterface defines the interface for resource management
|
// ResourceManagerInterface defines the interface for resource management
|
||||||
|
|
@ -25,7 +26,8 @@ type ResourceManagerInterface interface {
|
||||||
|
|
||||||
// EdgeConnectResourceManager implements resource management for EdgeConnect
|
// EdgeConnectResourceManager implements resource management for EdgeConnect
|
||||||
type EdgeConnectResourceManager struct {
|
type EdgeConnectResourceManager struct {
|
||||||
client EdgeConnectClientInterface
|
appRepo driven.AppRepository
|
||||||
|
appInstRepo driven.AppInstanceRepository
|
||||||
parallelLimit int
|
parallelLimit int
|
||||||
rollbackOnFail bool
|
rollbackOnFail bool
|
||||||
logger Logger
|
logger Logger
|
||||||
|
|
@ -66,14 +68,15 @@ func DefaultResourceManagerOptions() ResourceManagerOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewResourceManager creates a new EdgeConnect resource manager
|
// NewResourceManager creates a new EdgeConnect resource manager
|
||||||
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
func NewResourceManager(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
||||||
options := DefaultResourceManagerOptions()
|
options := DefaultResourceManagerOptions()
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(&options)
|
opt(&options)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &EdgeConnectResourceManager{
|
return &EdgeConnectResourceManager{
|
||||||
client: client,
|
appRepo: appRepo,
|
||||||
|
appInstRepo: appInstRepo,
|
||||||
parallelLimit: options.ParallelLimit,
|
parallelLimit: options.ParallelLimit,
|
||||||
rollbackOnFail: options.RollbackOnFail,
|
rollbackOnFail: options.RollbackOnFail,
|
||||||
logger: options.Logger,
|
logger: options.Logger,
|
||||||
|
|
@ -133,7 +136,7 @@ func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan
|
||||||
strategyConfig := rm.strategyConfig
|
strategyConfig := rm.strategyConfig
|
||||||
strategyConfig.ParallelOperations = rm.parallelLimit > 1
|
strategyConfig.ParallelOperations = rm.parallelLimit > 1
|
||||||
|
|
||||||
factory := NewStrategyFactory(rm.client, strategyConfig, rm.logger)
|
factory := NewStrategyFactory(rm.appRepo, rm.appInstRepo, strategyConfig, rm.logger)
|
||||||
strategy, err := factory.CreateStrategy(strategyName)
|
strategy, err := factory.CreateStrategy(strategyName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result := &ExecutionResult{
|
result := &ExecutionResult{
|
||||||
|
|
@ -190,8 +193,8 @@ func (rm *EdgeConnectResourceManager) ValidatePrerequisites(ctx context.Context,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that we have required client capabilities
|
// Validate that we have required client capabilities
|
||||||
if rm.client == nil {
|
if rm.appRepo == nil || rm.appInstRepo == nil {
|
||||||
return fmt.Errorf("EdgeConnect client is not configured")
|
return fmt.Errorf("repositories are not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
rm.logf("Prerequisites validation passed")
|
rm.logf("Prerequisites validation passed")
|
||||||
|
|
@ -250,13 +253,13 @@ func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context,
|
||||||
|
|
||||||
// rollbackApp deletes an application that was created
|
// rollbackApp deletes an application that was created
|
||||||
func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
|
func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
|
||||||
appKey := edgeconnect.AppKey{
|
appKey := domain.AppKey{
|
||||||
Organization: plan.AppAction.Desired.Organization,
|
Organization: plan.AppAction.Desired.Organization,
|
||||||
Name: plan.AppAction.Desired.Name,
|
Name: plan.AppAction.Desired.Name,
|
||||||
Version: plan.AppAction.Desired.Version,
|
Version: plan.AppAction.Desired.Version,
|
||||||
}
|
}
|
||||||
|
|
||||||
return rm.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region)
|
return rm.appRepo.DeleteApp(ctx, plan.AppAction.Desired.Region, appKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// rollbackInstance deletes an instance that was created
|
// rollbackInstance deletes an instance that was created
|
||||||
|
|
@ -264,15 +267,15 @@ func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, acti
|
||||||
// Find the instance action to get the details
|
// Find the instance action to get the details
|
||||||
for _, instanceAction := range plan.InstanceActions {
|
for _, instanceAction := range plan.InstanceActions {
|
||||||
if instanceAction.InstanceName == action.Target {
|
if instanceAction.InstanceName == action.Target {
|
||||||
instanceKey := edgeconnect.AppInstanceKey{
|
instanceKey := domain.AppInstanceKey{
|
||||||
Organization: plan.AppAction.Desired.Organization,
|
Organization: plan.AppAction.Desired.Organization,
|
||||||
Name: instanceAction.InstanceName,
|
Name: instanceAction.InstanceName,
|
||||||
CloudletKey: edgeconnect.CloudletKey{
|
CloudletKey: domain.CloudletKey{
|
||||||
Organization: instanceAction.Target.CloudletOrg,
|
Organization: instanceAction.Target.CloudletOrg,
|
||||||
Name: instanceAction.Target.CloudletName,
|
Name: instanceAction.Target.CloudletName,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return rm.client.DeleteAppInstance(ctx, instanceKey, instanceAction.Target.Region)
|
return rm.appInstRepo.DeleteAppInstance(ctx, instanceAction.Target.Region, instanceKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("instance action not found for rollback: %s", action.Target)
|
return fmt.Errorf("instance action not found for rollback: %s", action.Target)
|
||||||
|
|
@ -283,4 +286,4 @@ func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
|
||||||
if rm.logger != nil {
|
if rm.logger != nil {
|
||||||
rm.logger.Printf("[ResourceManager] "+format, v...)
|
rm.logger.Printf("[ResourceManager] "+format, v...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios
|
// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios
|
||||||
// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients
|
// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients
|
||||||
package v1
|
package apply
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -10,8 +10,8 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
@ -19,36 +19,61 @@ import (
|
||||||
|
|
||||||
// MockResourceClient extends MockEdgeConnectClient with resource management methods
|
// MockResourceClient extends MockEdgeConnectClient with resource management methods
|
||||||
type MockResourceClient struct {
|
type MockResourceClient struct {
|
||||||
MockEdgeConnectClient
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockResourceClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error {
|
func (m *MockResourceClient) CreateApp(ctx context.Context, region string, app *domain.App) error {
|
||||||
args := m.Called(ctx, input)
|
args := m.Called(ctx, region, app)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error {
|
func (m *MockResourceClient) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) {
|
||||||
args := m.Called(ctx, input)
|
args := m.Called(ctx, region, appKey)
|
||||||
|
return args.Get(0).(*domain.App), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockResourceClient) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) {
|
||||||
|
args := m.Called(ctx, region, appKey)
|
||||||
|
return args.Get(0).([]domain.App), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockResourceClient) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error {
|
||||||
|
args := m.Called(ctx, region, appKey)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error {
|
func (m *MockResourceClient) UpdateApp(ctx context.Context, region string, app *domain.App) error {
|
||||||
args := m.Called(ctx, appKey, region)
|
args := m.Called(ctx, region, app)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockResourceClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error {
|
func (m *MockResourceClient) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
|
||||||
args := m.Called(ctx, input)
|
args := m.Called(ctx, region, appInst)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error {
|
func (m *MockResourceClient) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) {
|
||||||
args := m.Called(ctx, input)
|
args := m.Called(ctx, region, appInstKey)
|
||||||
|
return args.Get(0).(*domain.AppInstance), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockResourceClient) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) {
|
||||||
|
args := m.Called(ctx, region, appInstKey)
|
||||||
|
return args.Get(0).([]domain.AppInstance), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
|
||||||
|
args := m.Called(ctx, region, appInstKey)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error {
|
func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
|
||||||
args := m.Called(ctx, instanceKey, region)
|
args := m.Called(ctx, region, appInst)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockResourceClient) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
|
||||||
|
args := m.Called(ctx, region, appInstKey)
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,8 +87,9 @@ func (l *TestLogger) Printf(format string, v ...interface{}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewResourceManager(t *testing.T) {
|
func TestNewResourceManager(t *testing.T) {
|
||||||
mockClient := &MockResourceClient{}
|
mockAppRepo := &MockResourceClient{}
|
||||||
manager := NewResourceManager(mockClient)
|
mockAppInstRepo := &MockResourceClient{}
|
||||||
|
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
assert.NotNil(t, manager)
|
assert.NotNil(t, manager)
|
||||||
assert.IsType(t, &EdgeConnectResourceManager{}, manager)
|
assert.IsType(t, &EdgeConnectResourceManager{}, manager)
|
||||||
|
|
@ -78,10 +104,11 @@ func TestDefaultResourceManagerOptions(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithOptions(t *testing.T) {
|
func TestWithOptions(t *testing.T) {
|
||||||
mockClient := &MockResourceClient{}
|
mockAppRepo := &MockResourceClient{}
|
||||||
|
mockAppInstRepo := &MockResourceClient{}
|
||||||
logger := &TestLogger{}
|
logger := &TestLogger{}
|
||||||
|
|
||||||
manager := NewResourceManager(mockClient,
|
manager := NewResourceManager(mockAppRepo, mockAppInstRepo,
|
||||||
WithParallelLimit(10),
|
WithParallelLimit(10),
|
||||||
WithRollbackOnFail(false),
|
WithRollbackOnFail(false),
|
||||||
WithLogger(logger),
|
WithLogger(logger),
|
||||||
|
|
@ -177,17 +204,18 @@ func createTestStrategyConfig() StrategyConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyDeploymentSuccess(t *testing.T) {
|
func TestApplyDeploymentSuccess(t *testing.T) {
|
||||||
mockClient := &MockResourceClient{}
|
mockAppRepo := &MockResourceClient{}
|
||||||
|
mockAppInstRepo := &MockResourceClient{}
|
||||||
logger := &TestLogger{}
|
logger := &TestLogger{}
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||||
|
|
||||||
plan := createTestDeploymentPlan()
|
plan := createTestDeploymentPlan()
|
||||||
config := createTestManagerConfig(t)
|
config := createTestManagerConfig(t)
|
||||||
|
|
||||||
// Mock successful operations
|
// Mock successful operations
|
||||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")).
|
mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
|
||||||
Return(nil)
|
Return(nil)
|
||||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")).
|
mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")).
|
||||||
Return(nil)
|
Return(nil)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -204,20 +232,22 @@ func TestApplyDeploymentSuccess(t *testing.T) {
|
||||||
// Check that operations were logged
|
// Check that operations were logged
|
||||||
assert.Greater(t, len(logger.messages), 0)
|
assert.Greater(t, len(logger.messages), 0)
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyDeploymentAppFailure(t *testing.T) {
|
func TestApplyDeploymentAppFailure(t *testing.T) {
|
||||||
mockClient := &MockResourceClient{}
|
mockAppRepo := &MockResourceClient{}
|
||||||
|
mockAppInstRepo := &MockResourceClient{}
|
||||||
logger := &TestLogger{}
|
logger := &TestLogger{}
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||||
|
|
||||||
plan := createTestDeploymentPlan()
|
plan := createTestDeploymentPlan()
|
||||||
config := createTestManagerConfig(t)
|
config := createTestManagerConfig(t)
|
||||||
|
|
||||||
// Mock app creation failure - deployment should stop here
|
// Mock app creation failure - deployment should stop here
|
||||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")).
|
mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
|
||||||
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}})
|
Return(fmt.Errorf("Server error"))
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
||||||
|
|
@ -229,25 +259,27 @@ func TestApplyDeploymentAppFailure(t *testing.T) {
|
||||||
assert.Len(t, result.FailedActions, 1)
|
assert.Len(t, result.FailedActions, 1)
|
||||||
assert.Contains(t, err.Error(), "Server error")
|
assert.Contains(t, err.Error(), "Server error")
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
|
func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
|
||||||
mockClient := &MockResourceClient{}
|
mockAppRepo := &MockResourceClient{}
|
||||||
|
mockAppInstRepo := &MockResourceClient{}
|
||||||
logger := &TestLogger{}
|
logger := &TestLogger{}
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig()))
|
manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig()))
|
||||||
|
|
||||||
plan := createTestDeploymentPlan()
|
plan := createTestDeploymentPlan()
|
||||||
config := createTestManagerConfig(t)
|
config := createTestManagerConfig(t)
|
||||||
|
|
||||||
// Mock successful app creation but failed instance creation
|
// Mock successful app creation but failed instance creation
|
||||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")).
|
mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
|
||||||
Return(nil)
|
Return(nil)
|
||||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")).
|
mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")).
|
||||||
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}})
|
Return(fmt.Errorf("Instance creation failed"))
|
||||||
|
|
||||||
// Mock rollback operations
|
// Mock rollback operations
|
||||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
|
mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")).
|
||||||
Return(nil)
|
Return(nil)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -262,12 +294,14 @@ func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
|
||||||
assert.True(t, result.RollbackSuccess)
|
assert.True(t, result.RollbackSuccess)
|
||||||
assert.Contains(t, err.Error(), "failed to create instance")
|
assert.Contains(t, err.Error(), "failed to create instance")
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyDeploymentNoActions(t *testing.T) {
|
func TestApplyDeploymentNoActions(t *testing.T) {
|
||||||
mockClient := &MockResourceClient{}
|
mockAppRepo := &MockResourceClient{}
|
||||||
manager := NewResourceManager(mockClient)
|
mockAppInstRepo := &MockResourceClient{}
|
||||||
|
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
// Create empty plan
|
// Create empty plan
|
||||||
plan := &DeploymentPlan{
|
plan := &DeploymentPlan{
|
||||||
|
|
@ -283,14 +317,15 @@ func TestApplyDeploymentNoActions(t *testing.T) {
|
||||||
require.NotNil(t, result)
|
require.NotNil(t, result)
|
||||||
assert.Contains(t, err.Error(), "deployment plan is empty")
|
assert.Contains(t, err.Error(), "deployment plan is empty")
|
||||||
|
|
||||||
mockClient.AssertNotCalled(t, "CreateApp")
|
mockAppRepo.AssertNotCalled(t, "CreateApp")
|
||||||
mockClient.AssertNotCalled(t, "CreateAppInstance")
|
mockAppInstRepo.AssertNotCalled(t, "CreateAppInstance")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyDeploymentMultipleInstances(t *testing.T) {
|
func TestApplyDeploymentMultipleInstances(t *testing.T) {
|
||||||
mockClient := &MockResourceClient{}
|
mockAppRepo := &MockResourceClient{}
|
||||||
|
mockAppInstRepo := &MockResourceClient{}
|
||||||
logger := &TestLogger{}
|
logger := &TestLogger{}
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig()))
|
manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig()))
|
||||||
|
|
||||||
// Create plan with multiple instances
|
// Create plan with multiple instances
|
||||||
plan := &DeploymentPlan{
|
plan := &DeploymentPlan{
|
||||||
|
|
@ -333,9 +368,9 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
|
||||||
config := createTestManagerConfig(t)
|
config := createTestManagerConfig(t)
|
||||||
|
|
||||||
// Mock successful operations
|
// Mock successful operations
|
||||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInput")).
|
mockAppRepo.On("CreateApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.App")).
|
||||||
Return(nil)
|
Return(nil)
|
||||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*edgeconnect.NewAppInstanceInput")).
|
mockAppInstRepo.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("*domain.AppInstance")).
|
||||||
Return(nil)
|
Return(nil)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -347,12 +382,14 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
|
||||||
assert.Len(t, result.CompletedActions, 3) // 1 app + 2 instances
|
assert.Len(t, result.CompletedActions, 3) // 1 app + 2 instances
|
||||||
assert.Len(t, result.FailedActions, 0)
|
assert.Len(t, result.FailedActions, 0)
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatePrerequisites(t *testing.T) {
|
func TestValidatePrerequisites(t *testing.T) {
|
||||||
mockClient := &MockResourceClient{}
|
mockAppRepo := &MockResourceClient{}
|
||||||
manager := NewResourceManager(mockClient)
|
mockAppInstRepo := &MockResourceClient{}
|
||||||
|
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -397,9 +434,10 @@ func TestValidatePrerequisites(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRollbackDeployment(t *testing.T) {
|
func TestRollbackDeployment(t *testing.T) {
|
||||||
mockClient := &MockResourceClient{}
|
mockAppRepo := &MockResourceClient{}
|
||||||
|
mockAppInstRepo := &MockResourceClient{}
|
||||||
logger := &TestLogger{}
|
logger := &TestLogger{}
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
manager := NewResourceManager(mockAppRepo, mockAppInstRepo, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||||
|
|
||||||
// Create result with completed actions
|
// Create result with completed actions
|
||||||
plan := createTestDeploymentPlan()
|
plan := createTestDeploymentPlan()
|
||||||
|
|
@ -421,24 +459,26 @@ func TestRollbackDeployment(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock rollback operations
|
// Mock rollback operations
|
||||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US").
|
mockAppInstRepo.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppInstanceKey")).
|
||||||
Return(nil)
|
Return(nil)
|
||||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
|
mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")).
|
||||||
Return(nil)
|
Return(nil)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := manager.RollbackDeployment(ctx, result)
|
err := manager.RollbackDeployment(ctx, result)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
mockClient.AssertExpectations(t)
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
|
|
||||||
// Check rollback was logged
|
// Check rollback was logged
|
||||||
assert.Greater(t, len(logger.messages), 0)
|
assert.Greater(t, len(logger.messages), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRollbackDeploymentFailure(t *testing.T) {
|
func TestRollbackDeploymentFailure(t *testing.T) {
|
||||||
mockClient := &MockResourceClient{}
|
mockAppRepo := &MockResourceClient{}
|
||||||
manager := NewResourceManager(mockClient)
|
mockAppInstRepo := &MockResourceClient{}
|
||||||
|
manager := NewResourceManager(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
plan := createTestDeploymentPlan()
|
plan := createTestDeploymentPlan()
|
||||||
result := &ExecutionResult{
|
result := &ExecutionResult{
|
||||||
|
|
@ -453,15 +493,16 @@ func TestRollbackDeploymentFailure(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock rollback failure
|
// Mock rollback failure
|
||||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
|
mockAppRepo.On("DeleteApp", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppKey")).
|
||||||
Return(&edgeconnect.APIError{StatusCode: 500, Messages: []string{"Delete failed"}})
|
Return(fmt.Errorf("Delete failed"))
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := manager.RollbackDeployment(ctx, result)
|
err := manager.RollbackDeployment(ctx, result)
|
||||||
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "rollback encountered")
|
assert.Contains(t, err.Error(), "rollback encountered")
|
||||||
mockClient.AssertExpectations(t)
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertNetworkRules(t *testing.T) {
|
func TestConvertNetworkRules(t *testing.T) {
|
||||||
138
internal/application/apply/mocks_test.go
Normal file
138
internal/application/apply/mocks_test.go
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockAppRepository is a mock implementation of driven.AppRepository
|
||||||
|
type MockAppRepository struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppRepository) CreateApp(ctx context.Context, region string, app *domain.App) error {
|
||||||
|
args := m.Called(ctx, region, app)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppRepository) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) {
|
||||||
|
args := m.Called(ctx, region, appKey)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*domain.App), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppRepository) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) {
|
||||||
|
args := m.Called(ctx, region, appKey)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).([]domain.App), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppRepository) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error {
|
||||||
|
args := m.Called(ctx, region, appKey)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppRepository) UpdateApp(ctx context.Context, region string, app *domain.App) error {
|
||||||
|
args := m.Called(ctx, region, app)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockAppInstanceRepository is a mock implementation of driven.AppInstanceRepository
|
||||||
|
type MockAppInstanceRepository struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppInstanceRepository) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
|
||||||
|
args := m.Called(ctx, region, appInst)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppInstanceRepository) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) {
|
||||||
|
args := m.Called(ctx, region, appInstKey)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*domain.AppInstance), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppInstanceRepository) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) {
|
||||||
|
args := m.Called(ctx, region, appInstKey)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).([]domain.AppInstance), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppInstanceRepository) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
|
||||||
|
args := m.Called(ctx, region, appInstKey)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppInstanceRepository) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
|
||||||
|
args := m.Called(ctx, region, appInst)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppInstanceRepository) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
|
||||||
|
args := m.Called(ctx, region, appInstKey)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockConfigRepository is a mock implementation of driven.ConfigRepository
|
||||||
|
type MockConfigRepository struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockConfigRepository) ParseFile(path string) (*config.EdgeConnectConfig, string, error) {
|
||||||
|
args := m.Called(path)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.String(1), args.Error(2)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*config.EdgeConnectConfig), args.String(1), args.Error(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockConfigRepository) Validate(cfg *config.EdgeConnectConfig) error {
|
||||||
|
args := m.Called(cfg)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestPlanner creates a planner with mock repositories for testing
|
||||||
|
func NewTestPlanner(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) Planner {
|
||||||
|
if appRepo == nil {
|
||||||
|
appRepo = new(MockAppRepository)
|
||||||
|
}
|
||||||
|
if appInstRepo == nil {
|
||||||
|
appInstRepo = new(MockAppInstanceRepository)
|
||||||
|
}
|
||||||
|
return NewPlanner(appRepo, appInstRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestResourceManager creates a resource manager with mock repositories for testing
|
||||||
|
func NewTestResourceManager(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) ResourceManagerInterface {
|
||||||
|
if appRepo == nil {
|
||||||
|
appRepo = new(MockAppRepository)
|
||||||
|
}
|
||||||
|
if appInstRepo == nil {
|
||||||
|
appInstRepo = new(MockAppInstanceRepository)
|
||||||
|
}
|
||||||
|
return NewResourceManager(appRepo, appInstRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestStrategyFactory creates a strategy factory with mock repositories for testing
|
||||||
|
func NewTestStrategyFactory(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) *StrategyFactory {
|
||||||
|
if appRepo == nil {
|
||||||
|
appRepo = new(MockAppRepository)
|
||||||
|
}
|
||||||
|
if appInstRepo == nil {
|
||||||
|
appInstRepo = new(MockAppInstanceRepository)
|
||||||
|
}
|
||||||
|
return NewStrategyFactory(appRepo, appInstRepo, DefaultStrategyConfig(), nil)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison
|
// ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison
|
||||||
// ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls
|
// ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls
|
||||||
package v2
|
package apply
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -11,22 +11,11 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EdgeConnectClientInterface defines the methods needed for deployment planning
|
|
||||||
type EdgeConnectClientInterface interface {
|
|
||||||
ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error)
|
|
||||||
CreateApp(ctx context.Context, input *v2.NewAppInput) error
|
|
||||||
UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error
|
|
||||||
DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error
|
|
||||||
ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) (v2.AppInstance, error)
|
|
||||||
CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error
|
|
||||||
UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error
|
|
||||||
DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Planner defines the interface for deployment planning
|
// Planner defines the interface for deployment planning
|
||||||
type Planner interface {
|
type Planner interface {
|
||||||
// Plan analyzes the configuration and current state to generate a deployment plan
|
// Plan analyzes the configuration and current state to generate a deployment plan
|
||||||
|
|
@ -67,13 +56,15 @@ func DefaultPlanOptions() PlanOptions {
|
||||||
|
|
||||||
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
|
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
|
||||||
type EdgeConnectPlanner struct {
|
type EdgeConnectPlanner struct {
|
||||||
client EdgeConnectClientInterface
|
appRepo driven.AppRepository
|
||||||
|
appInstRepo driven.AppInstanceRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPlanner creates a new EdgeConnect deployment planner
|
// NewPlanner creates a new EdgeConnect deployment planner
|
||||||
func NewPlanner(client EdgeConnectClientInterface) Planner {
|
func NewPlanner(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository) Planner {
|
||||||
return &EdgeConnectPlanner{
|
return &EdgeConnectPlanner{
|
||||||
client: client,
|
appRepo: appRepo,
|
||||||
|
appInstRepo: appInstRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,9 +126,9 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E
|
||||||
desired := &AppState{
|
desired := &AppState{
|
||||||
Name: config.Metadata.Name,
|
Name: config.Metadata.Name,
|
||||||
Version: config.Metadata.AppVersion,
|
Version: config.Metadata.AppVersion,
|
||||||
Organization: config.Metadata.Organization, // Use first infra template for org
|
Organization: config.Metadata.Organization, // Use first infra template for org
|
||||||
Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region
|
Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region
|
||||||
Exists: false, // Will be set based on current state
|
Exists: false, // Will be set based on current state
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Spec.IsK8sApp() {
|
if config.Spec.IsK8sApp() {
|
||||||
|
|
@ -148,9 +139,9 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E
|
||||||
|
|
||||||
// Extract outbound connections from config
|
// Extract outbound connections from config
|
||||||
if config.Spec.Network != nil {
|
if config.Spec.Network != nil {
|
||||||
desired.OutboundConnections = make([]SecurityRule, len(config.Spec.Network.OutboundConnections))
|
desired.OutboundConnections = make([]domain.SecurityRule, len(config.Spec.Network.OutboundConnections))
|
||||||
for i, conn := range config.Spec.Network.OutboundConnections {
|
for i, conn := range config.Spec.Network.OutboundConnections {
|
||||||
desired.OutboundConnections[i] = SecurityRule{
|
desired.OutboundConnections[i] = domain.SecurityRule{
|
||||||
Protocol: conn.Protocol,
|
Protocol: conn.Protocol,
|
||||||
PortRangeMin: conn.PortRangeMin,
|
PortRangeMin: conn.PortRangeMin,
|
||||||
PortRangeMax: conn.PortRangeMax,
|
PortRangeMax: conn.PortRangeMax,
|
||||||
|
|
@ -285,13 +276,13 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
appKey := v2.AppKey{
|
appKey := domain.AppKey{
|
||||||
Organization: desired.Organization,
|
Organization: desired.Organization,
|
||||||
Name: desired.Name,
|
Name: desired.Name,
|
||||||
Version: desired.Version,
|
Version: desired.Version,
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := p.client.ShowApp(timeoutCtx, appKey, desired.Region)
|
app, err := p.appRepo.ShowApp(timeoutCtx, desired.Region, appKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -321,9 +312,14 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract outbound connections from the app
|
// Extract outbound connections from the app
|
||||||
current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections))
|
current.OutboundConnections = make([]domain.SecurityRule, len(app.RequiredOutboundConnections))
|
||||||
for i, conn := range app.RequiredOutboundConnections {
|
for i, conn := range app.RequiredOutboundConnections {
|
||||||
current.OutboundConnections[i] = SecurityRule(conn)
|
current.OutboundConnections[i] = domain.SecurityRule{
|
||||||
|
Protocol: conn.Protocol,
|
||||||
|
PortRangeMin: conn.PortRangeMin,
|
||||||
|
PortRangeMax: conn.PortRangeMax,
|
||||||
|
RemoteCIDR: conn.RemoteCIDR,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return current, nil
|
return current, nil
|
||||||
|
|
@ -334,18 +330,16 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
instanceKey := v2.AppInstanceKey{
|
instanceKey := domain.AppInstanceKey{
|
||||||
Organization: desired.Organization,
|
Organization: desired.Organization,
|
||||||
Name: desired.Name,
|
Name: desired.Name,
|
||||||
CloudletKey: v2.CloudletKey{
|
CloudletKey: domain.CloudletKey{
|
||||||
Organization: desired.CloudletOrg,
|
Organization: desired.CloudletOrg,
|
||||||
Name: desired.CloudletName,
|
Name: desired.CloudletName,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
appKey := v2.AppKey{Name: desired.AppName}
|
instance, err := p.appInstRepo.ShowAppInstance(timeoutCtx, desired.Region, instanceKey)
|
||||||
|
|
||||||
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -389,7 +383,7 @@ func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]str
|
||||||
// Compare outbound connections
|
// Compare outbound connections
|
||||||
outboundChanges := p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections)
|
outboundChanges := p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections)
|
||||||
if len(outboundChanges) > 0 {
|
if len(outboundChanges) > 0 {
|
||||||
sb := strings.Builder{}
|
sb:= strings.Builder{}
|
||||||
sb.WriteString("Outbound connections changed:\n")
|
sb.WriteString("Outbound connections changed:\n")
|
||||||
for _, change := range outboundChanges {
|
for _, change := range outboundChanges {
|
||||||
sb.WriteString(change)
|
sb.WriteString(change)
|
||||||
|
|
@ -402,10 +396,10 @@ func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]str
|
||||||
}
|
}
|
||||||
|
|
||||||
// compareOutboundConnections compares two sets of outbound connections for equality
|
// compareOutboundConnections compares two sets of outbound connections for equality
|
||||||
func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) []string {
|
func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []domain.SecurityRule) []string {
|
||||||
var changes []string
|
var changes []string
|
||||||
makeMap := func(rules []SecurityRule) map[string]SecurityRule {
|
makeMap := func(rules []domain.SecurityRule) map[string]domain.SecurityRule {
|
||||||
m := make(map[string]SecurityRule, len(rules))
|
m := make(map[string]domain.SecurityRule, len(rules))
|
||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
key := fmt.Sprintf("%s:%d-%d:%s",
|
key := fmt.Sprintf("%s:%d-%d:%s",
|
||||||
strings.ToLower(r.Protocol),
|
strings.ToLower(r.Protocol),
|
||||||
|
|
@ -468,7 +462,10 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string,
|
||||||
return "", fmt.Errorf("failed to open manifest file: %w", err)
|
return "", fmt.Errorf("failed to open manifest file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = file.Close()
|
if err := file.Close(); err != nil {
|
||||||
|
// Log error but don't fail the operation as hash is already computed
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to close manifest file: %v\n", err)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
hasher := sha256.New()
|
hasher := sha256.New()
|
||||||
|
|
@ -553,4 +550,4 @@ func max(a, b time.Duration) time.Duration {
|
||||||
// getInstanceName generates the instance name following the pattern: appName-appVersion-instance
|
// getInstanceName generates the instance name following the pattern: appName-appVersion-instance
|
||||||
func getInstanceName(appName, appVersion string) string {
|
func getInstanceName(appName, appVersion string) string {
|
||||||
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
|
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
|
||||||
}
|
}
|
||||||
760
internal/application/apply/planner_test.go
Normal file
760
internal/application/apply/planner_test.go
Normal file
|
|
@ -0,0 +1,760 @@
|
||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanner_Plan_CreateApp(t *testing.T) {
|
||||||
|
mockAppRepo := new(MockAppRepository)
|
||||||
|
mockAppInstRepo := new(MockAppInstanceRepository)
|
||||||
|
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
|
testConfig := &config.EdgeConnectConfig{
|
||||||
|
Metadata: config.Metadata{
|
||||||
|
Name: "test-app",
|
||||||
|
AppVersion: "1.0.0",
|
||||||
|
Organization: "test-org",
|
||||||
|
},
|
||||||
|
Spec: config.Spec{
|
||||||
|
K8sApp: &config.K8sApp{},
|
||||||
|
InfraTemplate: []config.InfraTemplate{
|
||||||
|
{
|
||||||
|
Region: "us-west",
|
||||||
|
CloudletOrg: "cloudlet-org",
|
||||||
|
CloudletName: "cloudlet-name",
|
||||||
|
FlavorName: "m4.small",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock app not found
|
||||||
|
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
}).Return(nil, fmt.Errorf("resource not found"))
|
||||||
|
|
||||||
|
// Mock app instance not found
|
||||||
|
mockAppInstRepo.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("domain.AppInstanceKey")).
|
||||||
|
Return(nil, fmt.Errorf("resource not found"))
|
||||||
|
|
||||||
|
result, err := planner.Plan(context.Background(), testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.NotNil(t, result.Plan)
|
||||||
|
assert.Equal(t, ActionCreate, result.Plan.AppAction.Type)
|
||||||
|
assert.Equal(t, "Application does not exist", result.Plan.AppAction.Reason)
|
||||||
|
assert.Len(t, result.Plan.InstanceActions, 1)
|
||||||
|
assert.Equal(t, ActionCreate, result.Plan.InstanceActions[0].Type)
|
||||||
|
assert.Equal(t, "Instance does not exist", result.Plan.InstanceActions[0].Reason)
|
||||||
|
assert.Equal(t, 2, result.Plan.TotalActions)
|
||||||
|
|
||||||
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanner_Plan_UpdateApp(t *testing.T) {
|
||||||
|
mockAppRepo := new(MockAppRepository)
|
||||||
|
mockAppInstRepo := new(MockAppInstanceRepository)
|
||||||
|
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
|
testConfig := &config.EdgeConnectConfig{
|
||||||
|
Metadata: config.Metadata{
|
||||||
|
Name: "test-app",
|
||||||
|
AppVersion: "1.0.0",
|
||||||
|
Organization: "test-org",
|
||||||
|
},
|
||||||
|
Spec: config.Spec{
|
||||||
|
K8sApp: &config.K8sApp{},
|
||||||
|
InfraTemplate: []config.InfraTemplate{
|
||||||
|
{
|
||||||
|
Region: "us-west",
|
||||||
|
CloudletOrg: "cloudlet-org",
|
||||||
|
CloudletName: "cloudlet-name",
|
||||||
|
FlavorName: "m4.small",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Network: &config.NetworkConfig{
|
||||||
|
OutboundConnections: []config.OutboundConnection{
|
||||||
|
{
|
||||||
|
Protocol: "tcp",
|
||||||
|
PortRangeMin: 80,
|
||||||
|
PortRangeMax: 80,
|
||||||
|
RemoteCIDR: "0.0.0.0/0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
existingApp := &domain.App{
|
||||||
|
Key: domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Deployment: "kubernetes",
|
||||||
|
RequiredOutboundConnections: []domain.SecurityRule{
|
||||||
|
{
|
||||||
|
Protocol: "tcp",
|
||||||
|
PortRangeMin: 8080,
|
||||||
|
PortRangeMax: 8080,
|
||||||
|
RemoteCIDR: "0.0.0.0/0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock app found
|
||||||
|
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
}).Return(existingApp, nil)
|
||||||
|
|
||||||
|
// Mock instance not found
|
||||||
|
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org",
|
||||||
|
Name: "cloudlet-name",
|
||||||
|
},
|
||||||
|
}).Return(nil, fmt.Errorf("resource not found"))
|
||||||
|
|
||||||
|
result, err := planner.Plan(context.Background(), testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.NotNil(t, result.Plan)
|
||||||
|
assert.Equal(t, ActionUpdate, result.Plan.AppAction.Type)
|
||||||
|
assert.Contains(t, result.Plan.AppAction.Reason, "Application configuration has changed")
|
||||||
|
assert.Len(t, result.Plan.InstanceActions, 1)
|
||||||
|
assert.Equal(t, ActionCreate, result.Plan.InstanceActions[0].Type)
|
||||||
|
assert.Equal(t, 2, result.Plan.TotalActions)
|
||||||
|
|
||||||
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanner_Plan_NoChange(t *testing.T) {
|
||||||
|
mockAppRepo := new(MockAppRepository)
|
||||||
|
mockAppInstRepo := new(MockAppInstanceRepository)
|
||||||
|
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
|
testConfig := &config.EdgeConnectConfig{
|
||||||
|
Metadata: config.Metadata{
|
||||||
|
Name: "test-app",
|
||||||
|
AppVersion: "1.0.0",
|
||||||
|
Organization: "test-org",
|
||||||
|
},
|
||||||
|
Spec: config.Spec{
|
||||||
|
K8sApp: &config.K8sApp{},
|
||||||
|
InfraTemplate: []config.InfraTemplate{
|
||||||
|
{
|
||||||
|
Region: "us-west",
|
||||||
|
CloudletOrg: "cloudlet-org",
|
||||||
|
CloudletName: "cloudlet-name",
|
||||||
|
FlavorName: "m4.small",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Network: &config.NetworkConfig{
|
||||||
|
OutboundConnections: []config.OutboundConnection{
|
||||||
|
{
|
||||||
|
Protocol: "tcp",
|
||||||
|
PortRangeMin: 80,
|
||||||
|
PortRangeMax: 80,
|
||||||
|
RemoteCIDR: "0.0.0.0/0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
existingApp := &domain.App{
|
||||||
|
Key: domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Deployment: "kubernetes",
|
||||||
|
RequiredOutboundConnections: []domain.SecurityRule{
|
||||||
|
{
|
||||||
|
Protocol: "tcp",
|
||||||
|
PortRangeMin: 80,
|
||||||
|
PortRangeMax: 80,
|
||||||
|
RemoteCIDR: "0.0.0.0/0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
existingInstance := &domain.AppInstance{
|
||||||
|
Key: domain.AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org",
|
||||||
|
Name: "cloudlet-name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AppKey: domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Flavor: domain.Flavor{Name: "m4.small"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock app found
|
||||||
|
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
}).Return(existingApp, nil)
|
||||||
|
|
||||||
|
// Mock instance found
|
||||||
|
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
|
||||||
|
Organization: "test-org", Name: "test-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org",
|
||||||
|
Name: "cloudlet-name",
|
||||||
|
},
|
||||||
|
}).Return(existingInstance, nil)
|
||||||
|
|
||||||
|
result, err := planner.Plan(context.Background(), testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.NotNil(t, result.Plan)
|
||||||
|
assert.Equal(t, ActionNone, result.Plan.AppAction.Type)
|
||||||
|
assert.Equal(t, ActionNone, result.Plan.InstanceActions[0].Type)
|
||||||
|
assert.Equal(t, 0, result.Plan.TotalActions)
|
||||||
|
|
||||||
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanner_Plan_SkipStateCheck(t *testing.T) {
|
||||||
|
mockAppRepo := new(MockAppRepository)
|
||||||
|
mockAppInstRepo := new(MockAppInstanceRepository)
|
||||||
|
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
|
testConfig := &config.EdgeConnectConfig{
|
||||||
|
Metadata: config.Metadata{
|
||||||
|
Name: "test-app",
|
||||||
|
AppVersion: "1.0.0",
|
||||||
|
Organization: "test-org",
|
||||||
|
},
|
||||||
|
Spec: config.Spec{
|
||||||
|
K8sApp: &config.K8sApp{},
|
||||||
|
InfraTemplate: []config.InfraTemplate{
|
||||||
|
{
|
||||||
|
Region: "us-west",
|
||||||
|
CloudletOrg: "cloudlet-org",
|
||||||
|
CloudletName: "cloudlet-name",
|
||||||
|
FlavorName: "m4.small",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := DefaultPlanOptions()
|
||||||
|
opts.SkipStateCheck = true
|
||||||
|
|
||||||
|
result, err := planner.PlanWithOptions(context.Background(), testConfig, opts)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.NotNil(t, result.Plan)
|
||||||
|
assert.Equal(t, ActionCreate, result.Plan.AppAction.Type)
|
||||||
|
assert.Equal(t, "Creating app (state check skipped)", result.Plan.AppAction.Reason)
|
||||||
|
assert.Len(t, result.Plan.InstanceActions, 1)
|
||||||
|
assert.Equal(t, ActionCreate, result.Plan.InstanceActions[0].Type)
|
||||||
|
assert.Equal(t, "Creating instance (state check skipped)", result.Plan.InstanceActions[0].Reason)
|
||||||
|
assert.Equal(t, 2, result.Plan.TotalActions)
|
||||||
|
|
||||||
|
mockAppRepo.AssertNotCalled(t, "ShowApp", mock.Anything, mock.Anything, mock.Anything)
|
||||||
|
mockAppInstRepo.AssertNotCalled(t, "ShowAppInstance", mock.Anything, mock.Anything, mock.Anything)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanner_Plan_ManifestHashChange(t *testing.T) {
|
||||||
|
mockAppRepo := new(MockAppRepository)
|
||||||
|
mockAppInstRepo := new(MockAppInstanceRepository)
|
||||||
|
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
|
// Create a temporary manifest file
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
manifestPath := filepath.Join(tempDir, "manifest.yaml")
|
||||||
|
err := os.WriteFile(manifestPath, []byte("new manifest content"), 0644)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
testConfig := &config.EdgeConnectConfig{
|
||||||
|
Metadata: config.Metadata{
|
||||||
|
Name: "test-app",
|
||||||
|
AppVersion: "1.0.0",
|
||||||
|
Organization: "test-org",
|
||||||
|
},
|
||||||
|
Spec: config.Spec{
|
||||||
|
K8sApp: &config.K8sApp{
|
||||||
|
ManifestFile: manifestPath,
|
||||||
|
},
|
||||||
|
InfraTemplate: []config.InfraTemplate{
|
||||||
|
{
|
||||||
|
Region: "us-west",
|
||||||
|
CloudletOrg: "cloudlet-org",
|
||||||
|
CloudletName: "cloudlet-name",
|
||||||
|
FlavorName: "m4.small",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
existingApp := &domain.App{
|
||||||
|
Key: domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Deployment: "kubernetes",
|
||||||
|
DeploymentManifest: "old manifest content", // Different manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock app found
|
||||||
|
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
}).Return(existingApp, nil)
|
||||||
|
|
||||||
|
// Mock instance found (no change)
|
||||||
|
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org",
|
||||||
|
Name: "cloudlet-name",
|
||||||
|
},
|
||||||
|
}).Return(&domain.AppInstance{
|
||||||
|
Key: domain.AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org",
|
||||||
|
Name: "cloudlet-name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AppKey: domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Flavor: domain.Flavor{Name: "m4.small"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
result, err := planner.Plan(context.Background(), testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.NotNil(t, result.Plan)
|
||||||
|
assert.Equal(t, ActionUpdate, result.Plan.AppAction.Type)
|
||||||
|
assert.True(t, result.Plan.AppAction.ManifestChanged)
|
||||||
|
assert.Contains(t, result.Plan.AppAction.Reason, "Application configuration has changed")
|
||||||
|
assert.Contains(t, result.Warnings, "Manifest file has changed - instances may need to be recreated")
|
||||||
|
assert.Equal(t, 1, result.Plan.TotalActions) // Only app update, instance is ActionNone
|
||||||
|
|
||||||
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanner_Plan_InstanceFlavorChange(t *testing.T) {
|
||||||
|
mockAppRepo := new(MockAppRepository)
|
||||||
|
mockAppInstRepo := new(MockAppInstanceRepository)
|
||||||
|
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
|
testConfig := &config.EdgeConnectConfig{
|
||||||
|
Metadata: config.Metadata{
|
||||||
|
Name: "test-app",
|
||||||
|
AppVersion: "1.0.0",
|
||||||
|
Organization: "test-org",
|
||||||
|
},
|
||||||
|
Spec: config.Spec{
|
||||||
|
K8sApp: &config.K8sApp{},
|
||||||
|
InfraTemplate: []config.InfraTemplate{
|
||||||
|
{
|
||||||
|
Region: "us-west",
|
||||||
|
CloudletOrg: "cloudlet-org",
|
||||||
|
CloudletName: "cloudlet-name",
|
||||||
|
FlavorName: "m4.large", // Changed flavor
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
existingApp := &domain.App{
|
||||||
|
Key: domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Deployment: "kubernetes",
|
||||||
|
}
|
||||||
|
|
||||||
|
existingInstance := &domain.AppInstance{
|
||||||
|
Key: domain.AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org",
|
||||||
|
Name: "cloudlet-name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AppKey: domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Flavor: domain.Flavor{Name: "m4.small"}, // Old flavor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock app found
|
||||||
|
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
}).Return(existingApp, nil)
|
||||||
|
|
||||||
|
// Mock instance found
|
||||||
|
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org",
|
||||||
|
Name: "cloudlet-name",
|
||||||
|
},
|
||||||
|
}).Return(existingInstance, nil)
|
||||||
|
|
||||||
|
result, err := planner.Plan(context.Background(), testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.NotNil(t, result.Plan)
|
||||||
|
assert.Equal(t, ActionNone, result.Plan.AppAction.Type)
|
||||||
|
assert.Len(t, result.Plan.InstanceActions, 1)
|
||||||
|
assert.Equal(t, ActionUpdate, result.Plan.InstanceActions[0].Type)
|
||||||
|
assert.Contains(t, result.Plan.InstanceActions[0].Reason, "Instance configuration has changed")
|
||||||
|
assert.Contains(t, result.Plan.InstanceActions[0].Changes, "Flavor changed: m4.small -> m4.large")
|
||||||
|
assert.Equal(t, 1, result.Plan.TotalActions)
|
||||||
|
|
||||||
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanner_Plan_MultipleInstances(t *testing.T) {
|
||||||
|
mockAppRepo := new(MockAppRepository)
|
||||||
|
mockAppInstRepo := new(MockAppInstanceRepository)
|
||||||
|
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
|
testConfig := &config.EdgeConnectConfig{
|
||||||
|
Metadata: config.Metadata{
|
||||||
|
Name: "multi-app",
|
||||||
|
AppVersion: "1.0.0",
|
||||||
|
Organization: "test-org",
|
||||||
|
},
|
||||||
|
Spec: config.Spec{
|
||||||
|
K8sApp: &config.K8sApp{},
|
||||||
|
InfraTemplate: []config.InfraTemplate{
|
||||||
|
{
|
||||||
|
Region: "us-west",
|
||||||
|
CloudletOrg: "cloudlet-org-1",
|
||||||
|
CloudletName: "cloudlet-name-1",
|
||||||
|
FlavorName: "m4.small",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Region: "us-east",
|
||||||
|
CloudletOrg: "cloudlet-org-2",
|
||||||
|
CloudletName: "cloudlet-name-2",
|
||||||
|
FlavorName: "m4.medium",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
existingApp := &domain.App{
|
||||||
|
Key: domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "multi-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Deployment: "kubernetes",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock app found
|
||||||
|
mockAppRepo.On("ShowApp", mock.Anything, mock.AnythingOfType("string"), domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "multi-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
}).Return(existingApp, nil)
|
||||||
|
|
||||||
|
// Mock instance 1 not found
|
||||||
|
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "multi-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org-1",
|
||||||
|
Name: "cloudlet-name-1",
|
||||||
|
},
|
||||||
|
}).Return(nil, fmt.Errorf("resource not found"))
|
||||||
|
|
||||||
|
// Mock instance 2 found with different flavor
|
||||||
|
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-east", domain.AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "multi-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org-2",
|
||||||
|
Name: "cloudlet-name-2",
|
||||||
|
},
|
||||||
|
}).Return(&domain.AppInstance{
|
||||||
|
Key: domain.AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "multi-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org-2",
|
||||||
|
Name: "cloudlet-name-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AppKey: domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "multi-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Flavor: domain.Flavor{Name: "m4.small"}, // Different flavor
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
result, err := planner.Plan(context.Background(), testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.NotNil(t, result.Plan)
|
||||||
|
assert.Equal(t, ActionNone, result.Plan.AppAction.Type)
|
||||||
|
assert.Len(t, result.Plan.InstanceActions, 2)
|
||||||
|
assert.Equal(t, ActionCreate, result.Plan.InstanceActions[0].Type) // Instance 1 is new
|
||||||
|
assert.Equal(t, ActionUpdate, result.Plan.InstanceActions[1].Type) // Instance 2 has flavor change
|
||||||
|
assert.Contains(t, result.Plan.InstanceActions[1].Changes, "Flavor changed: m4.small -> m4.medium")
|
||||||
|
assert.Equal(t, 2, result.Plan.TotalActions)
|
||||||
|
|
||||||
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanner_Plan_AppQueryError(t *testing.T) {
|
||||||
|
mockAppRepo := new(MockAppRepository)
|
||||||
|
mockAppInstRepo := new(MockAppInstanceRepository)
|
||||||
|
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
|
testConfig := &config.EdgeConnectConfig{
|
||||||
|
Metadata: config.Metadata{
|
||||||
|
Name: "test-app",
|
||||||
|
AppVersion: "1.0.0",
|
||||||
|
Organization: "test-org",
|
||||||
|
},
|
||||||
|
Spec: config.Spec{
|
||||||
|
K8sApp: &config.K8sApp{},
|
||||||
|
InfraTemplate: []config.InfraTemplate{
|
||||||
|
{
|
||||||
|
Region: "us-west",
|
||||||
|
CloudletOrg: "cloudlet-org",
|
||||||
|
CloudletName: "cloudlet-name",
|
||||||
|
FlavorName: "m4.small",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock app query error
|
||||||
|
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
}).Return(nil, fmt.Errorf("network error"))
|
||||||
|
|
||||||
|
result, err := planner.Plan(context.Background(), testConfig)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "failed to query current app state: network error")
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
|
||||||
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanner_Plan_InstanceQueryError(t *testing.T) {
|
||||||
|
mockAppRepo := new(MockAppRepository)
|
||||||
|
mockAppInstRepo := new(MockAppInstanceRepository)
|
||||||
|
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
|
testConfig := &config.EdgeConnectConfig{
|
||||||
|
Metadata: config.Metadata{
|
||||||
|
Name: "test-app",
|
||||||
|
AppVersion: "1.0.0",
|
||||||
|
Organization: "test-org",
|
||||||
|
},
|
||||||
|
Spec: config.Spec{
|
||||||
|
K8sApp: &config.K8sApp{},
|
||||||
|
InfraTemplate: []config.InfraTemplate{
|
||||||
|
{
|
||||||
|
Region: "us-west",
|
||||||
|
CloudletOrg: "cloudlet-org",
|
||||||
|
CloudletName: "cloudlet-name",
|
||||||
|
FlavorName: "m4.small",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
existingApp := &domain.App{
|
||||||
|
Key: domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Deployment: "kubernetes",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock app found
|
||||||
|
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
}).Return(existingApp, nil)
|
||||||
|
|
||||||
|
// Mock instance query error
|
||||||
|
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org",
|
||||||
|
Name: "cloudlet-name",
|
||||||
|
},
|
||||||
|
}).Return(nil, fmt.Errorf("database error"))
|
||||||
|
|
||||||
|
result, err := planner.Plan(context.Background(), testConfig)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "failed to query current instance state: database error")
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
|
||||||
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanner_Plan_ManifestFileError(t *testing.T) {
|
||||||
|
mockAppRepo := new(MockAppRepository)
|
||||||
|
mockAppInstRepo := new(MockAppInstanceRepository)
|
||||||
|
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
|
testConfig := &config.EdgeConnectConfig{
|
||||||
|
Metadata: config.Metadata{
|
||||||
|
Name: "test-app",
|
||||||
|
AppVersion: "1.0.0",
|
||||||
|
Organization: "test-org",
|
||||||
|
},
|
||||||
|
Spec: config.Spec{
|
||||||
|
K8sApp: &config.K8sApp{
|
||||||
|
ManifestFile: "/non/existent/path/manifest.yaml", // Invalid path
|
||||||
|
},
|
||||||
|
InfraTemplate: []config.InfraTemplate{
|
||||||
|
{
|
||||||
|
Region: "us-west",
|
||||||
|
CloudletOrg: "cloudlet-org",
|
||||||
|
CloudletName: "cloudlet-name",
|
||||||
|
FlavorName: "m4.small",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := planner.Plan(context.Background(), testConfig)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "failed to calculate manifest hash: failed to open manifest file")
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
|
||||||
|
mockAppRepo.AssertNotCalled(t, "ShowApp", mock.Anything, mock.Anything, mock.Anything)
|
||||||
|
mockAppInstRepo.AssertNotCalled(t, "ShowAppInstance", mock.Anything, mock.Anything, mock.Anything)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanner_Plan_AppTypeChange(t *testing.T) {
|
||||||
|
mockAppRepo := new(MockAppRepository)
|
||||||
|
mockAppInstRepo := new(MockAppInstanceRepository)
|
||||||
|
planner := NewTestPlanner(mockAppRepo, mockAppInstRepo)
|
||||||
|
|
||||||
|
testConfig := &config.EdgeConnectConfig{
|
||||||
|
Metadata: config.Metadata{
|
||||||
|
Name: "test-app",
|
||||||
|
AppVersion: "1.0.0",
|
||||||
|
Organization: "test-org",
|
||||||
|
},
|
||||||
|
Spec: config.Spec{
|
||||||
|
DockerApp: &config.DockerApp{},
|
||||||
|
InfraTemplate: []config.InfraTemplate{
|
||||||
|
{
|
||||||
|
Region: "us-west",
|
||||||
|
CloudletOrg: "cloudlet-org",
|
||||||
|
CloudletName: "cloudlet-name",
|
||||||
|
FlavorName: "m4.small",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
existingApp := &domain.App{
|
||||||
|
Key: domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Deployment: "kubernetes", // Current is kubernetes
|
||||||
|
}
|
||||||
|
// Mock app found
|
||||||
|
mockAppRepo.On("ShowApp", mock.Anything, "us-west", domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
}).Return(existingApp, nil)
|
||||||
|
|
||||||
|
// Mock instance found (no change)
|
||||||
|
mockAppInstRepo.On("ShowAppInstance", mock.Anything, "us-west", domain.AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org",
|
||||||
|
Name: "cloudlet-name",
|
||||||
|
},
|
||||||
|
}).Return(&domain.AppInstance{
|
||||||
|
Key: domain.AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app-1.0.0-instance",
|
||||||
|
CloudletKey: domain.CloudletKey{
|
||||||
|
Organization: "cloudlet-org",
|
||||||
|
Name: "cloudlet-name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AppKey: domain.AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
},
|
||||||
|
Flavor: domain.Flavor{Name: "m4.small"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
result, err := planner.Plan(context.Background(), testConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.NotNil(t, result.Plan)
|
||||||
|
assert.Equal(t, ActionUpdate, result.Plan.AppAction.Type)
|
||||||
|
assert.Contains(t, result.Plan.AppAction.Changes, "App type changed: k8s -> docker")
|
||||||
|
assert.Equal(t, 1, result.Plan.TotalActions) // Only app update, instance is ActionNone
|
||||||
|
|
||||||
|
mockAppRepo.AssertExpectations(t)
|
||||||
|
mockAppInstRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
|
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
|
||||||
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
|
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
|
||||||
package v2
|
package apply
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeploymentStrategy represents the type of deployment strategy
|
// DeploymentStrategy represents the type of deployment strategy
|
||||||
|
|
@ -66,17 +67,19 @@ func DefaultStrategyConfig() StrategyConfig {
|
||||||
|
|
||||||
// StrategyFactory creates deployment strategy executors
|
// StrategyFactory creates deployment strategy executors
|
||||||
type StrategyFactory struct {
|
type StrategyFactory struct {
|
||||||
config StrategyConfig
|
config StrategyConfig
|
||||||
client EdgeConnectClientInterface
|
appRepo driven.AppRepository
|
||||||
logger Logger
|
appInstRepo driven.AppInstanceRepository
|
||||||
|
logger Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStrategyFactory creates a new strategy factory
|
// NewStrategyFactory creates a new strategy factory
|
||||||
func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *StrategyFactory {
|
func NewStrategyFactory(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, config StrategyConfig, logger Logger) *StrategyFactory {
|
||||||
return &StrategyFactory{
|
return &StrategyFactory{
|
||||||
config: config,
|
config: config,
|
||||||
client: client,
|
appRepo: appRepo,
|
||||||
logger: logger,
|
appInstRepo: appInstRepo,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,7 +87,7 @@ func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig
|
||||||
func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) {
|
func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) {
|
||||||
switch strategy {
|
switch strategy {
|
||||||
case StrategyRecreate:
|
case StrategyRecreate:
|
||||||
return NewRecreateStrategy(f.client, f.config, f.logger), nil
|
return NewRecreateStrategy(f.appRepo, f.appInstRepo, f.config, f.logger), nil
|
||||||
case StrategyBlueGreen:
|
case StrategyBlueGreen:
|
||||||
// TODO: Implement blue-green strategy
|
// TODO: Implement blue-green strategy
|
||||||
return nil, fmt.Errorf("blue-green strategy not yet implemented")
|
return nil, fmt.Errorf("blue-green strategy not yet implemented")
|
||||||
|
|
@ -103,4 +106,4 @@ func (f *StrategyFactory) GetAvailableStrategies() []DeploymentStrategy {
|
||||||
// StrategyBlueGreen, // TODO: Enable when implemented
|
// StrategyBlueGreen, // TODO: Enable when implemented
|
||||||
// StrategyRolling, // TODO: Enable when implemented
|
// StrategyRolling, // TODO: Enable when implemented
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,32 +1,34 @@
|
||||||
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect
|
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect
|
||||||
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
|
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
|
||||||
package v1
|
package apply
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RecreateStrategy implements the recreate deployment strategy
|
// RecreateStrategy implements the recreate deployment strategy
|
||||||
type RecreateStrategy struct {
|
type RecreateStrategy struct {
|
||||||
client EdgeConnectClientInterface
|
appRepo driven.AppRepository
|
||||||
config StrategyConfig
|
appInstRepo driven.AppInstanceRepository
|
||||||
logger Logger
|
config StrategyConfig
|
||||||
|
logger Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRecreateStrategy creates a new recreate strategy executor
|
// NewRecreateStrategy creates a new recreate strategy executor
|
||||||
func NewRecreateStrategy(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *RecreateStrategy {
|
func NewRecreateStrategy(appRepo driven.AppRepository, appInstRepo driven.AppInstanceRepository, config StrategyConfig, logger Logger) *RecreateStrategy {
|
||||||
return &RecreateStrategy{
|
return &RecreateStrategy{
|
||||||
client: client,
|
appRepo: appRepo,
|
||||||
config: config,
|
appInstRepo: appInstRepo,
|
||||||
logger: logger,
|
config: config,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,13 +186,13 @@ func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentP
|
||||||
|
|
||||||
r.logf("Phase 2: Deleting existing application")
|
r.logf("Phase 2: Deleting existing application")
|
||||||
|
|
||||||
appKey := edgeconnect.AppKey{
|
appKey := domain.AppKey{
|
||||||
Organization: plan.AppAction.Desired.Organization,
|
Organization: plan.AppAction.Desired.Organization,
|
||||||
Name: plan.AppAction.Desired.Name,
|
Name: plan.AppAction.Desired.Name,
|
||||||
Version: plan.AppAction.Desired.Version,
|
Version: plan.AppAction.Desired.Version,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region); err != nil {
|
if err := r.appRepo.DeleteApp(ctx, plan.AppAction.Desired.Region, appKey); err != nil {
|
||||||
result.FailedActions = append(result.FailedActions, ActionResult{
|
result.FailedActions = append(result.FailedActions, ActionResult{
|
||||||
Type: ActionDelete,
|
Type: ActionDelete,
|
||||||
Target: plan.AppAction.Desired.Name,
|
Target: plan.AppAction.Desired.Name,
|
||||||
|
|
@ -356,15 +358,6 @@ func (r *RecreateStrategy) executeInstanceActionWithRetry(ctx context.Context, a
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
// Check if error is retryable (don't retry 4xx client errors)
|
|
||||||
if !isRetryableError(err) {
|
|
||||||
r.logf("Failed to %s instance %s: %v (non-retryable error, giving up)", operation, action.InstanceName, err)
|
|
||||||
result.Error = fmt.Errorf("non-retryable error: %w", err)
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if attempt < r.config.MaxRetries {
|
if attempt < r.config.MaxRetries {
|
||||||
r.logf("Failed to %s instance %s: %v (will retry)", operation, action.InstanceName, err)
|
r.logf("Failed to %s instance %s: %v (will retry)", operation, action.InstanceName, err)
|
||||||
}
|
}
|
||||||
|
|
@ -396,7 +389,12 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
success, err := r.updateApplication(ctx, action, config, manifestContent)
|
var success bool
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// For recreate strategy, we always create the app
|
||||||
|
success, err = r.createApplication(ctx, action, config, manifestContent)
|
||||||
|
|
||||||
if success {
|
if success {
|
||||||
result.Success = true
|
result.Success = true
|
||||||
result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name)
|
result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name)
|
||||||
|
|
@ -405,15 +403,6 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
// Check if error is retryable (don't retry 4xx client errors)
|
|
||||||
if !isRetryableError(err) {
|
|
||||||
r.logf("Failed to update app: %v (non-retryable error, giving up)", err)
|
|
||||||
result.Error = fmt.Errorf("non-retryable error: %w", err)
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if attempt < r.config.MaxRetries {
|
if attempt < r.config.MaxRetries {
|
||||||
r.logf("Failed to update app: %v (will retry)", err)
|
r.logf("Failed to update app: %v (will retry)", err)
|
||||||
}
|
}
|
||||||
|
|
@ -426,16 +415,16 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action
|
||||||
|
|
||||||
// deleteInstance deletes an instance (reuse existing logic from manager.go)
|
// deleteInstance deletes an instance (reuse existing logic from manager.go)
|
||||||
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
|
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
|
||||||
instanceKey := edgeconnect.AppInstanceKey{
|
instanceKey := domain.AppInstanceKey{
|
||||||
Organization: action.Desired.Organization,
|
Organization: action.Desired.Organization,
|
||||||
Name: action.InstanceName,
|
Name: action.InstanceName,
|
||||||
CloudletKey: edgeconnect.CloudletKey{
|
CloudletKey: domain.CloudletKey{
|
||||||
Organization: action.Target.CloudletOrg,
|
Organization: action.Target.CloudletOrg,
|
||||||
Name: action.Target.CloudletName,
|
Name: action.Target.CloudletName,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.client.DeleteAppInstance(ctx, instanceKey, action.Target.Region)
|
err := r.appInstRepo.DeleteAppInstance(ctx, action.Target.Region, instanceKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to delete instance: %w", err)
|
return false, fmt.Errorf("failed to delete instance: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -445,30 +434,27 @@ func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAc
|
||||||
|
|
||||||
// createInstance creates an instance (extracted from manager.go logic)
|
// createInstance creates an instance (extracted from manager.go logic)
|
||||||
func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
|
func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
|
||||||
instanceInput := &edgeconnect.NewAppInstanceInput{
|
appInst := &domain.AppInstance{
|
||||||
Region: action.Target.Region,
|
Key: domain.AppInstanceKey{
|
||||||
AppInst: edgeconnect.AppInstance{
|
Organization: action.Desired.Organization,
|
||||||
Key: edgeconnect.AppInstanceKey{
|
Name: action.InstanceName,
|
||||||
Organization: action.Desired.Organization,
|
CloudletKey: domain.CloudletKey{
|
||||||
Name: action.InstanceName,
|
Organization: action.Target.CloudletOrg,
|
||||||
CloudletKey: edgeconnect.CloudletKey{
|
Name: action.Target.CloudletName,
|
||||||
Organization: action.Target.CloudletOrg,
|
|
||||||
Name: action.Target.CloudletName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppKey: edgeconnect.AppKey{
|
|
||||||
Organization: action.Desired.Organization,
|
|
||||||
Name: config.Metadata.Name,
|
|
||||||
Version: config.Metadata.AppVersion,
|
|
||||||
},
|
|
||||||
Flavor: edgeconnect.Flavor{
|
|
||||||
Name: action.Target.FlavorName,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
AppKey: domain.AppKey{
|
||||||
|
Organization: action.Desired.Organization,
|
||||||
|
Name: config.Metadata.Name,
|
||||||
|
Version: config.Metadata.AppVersion,
|
||||||
|
},
|
||||||
|
Flavor: domain.Flavor{
|
||||||
|
Name: action.Target.FlavorName,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the instance
|
// Create the instance
|
||||||
if err := r.client.CreateAppInstance(ctx, instanceInput); err != nil {
|
if err := r.appInstRepo.CreateAppInstance(ctx, action.Target.Region, appInst); err != nil {
|
||||||
return false, fmt.Errorf("failed to create instance: %w", err)
|
return false, fmt.Errorf("failed to create instance: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -478,35 +464,30 @@ func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAc
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateApplication creates/recreates an application (always uses CreateApp since we delete first)
|
// createApplication creates/recreates an application (always uses CreateApp since we delete first)
|
||||||
func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) {
|
func (r *RecreateStrategy) createApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) {
|
||||||
// Build the app create input - always create since recreate strategy deletes first
|
app := &domain.App{
|
||||||
appInput := &edgeconnect.NewAppInput{
|
Key: domain.AppKey{
|
||||||
Region: action.Desired.Region,
|
Organization: action.Desired.Organization,
|
||||||
App: edgeconnect.App{
|
Name: action.Desired.Name,
|
||||||
Key: edgeconnect.AppKey{
|
Version: action.Desired.Version,
|
||||||
Organization: action.Desired.Organization,
|
|
||||||
Name: action.Desired.Name,
|
|
||||||
Version: action.Desired.Version,
|
|
||||||
},
|
|
||||||
Deployment: config.GetDeploymentType(),
|
|
||||||
ImageType: "ImageTypeDocker",
|
|
||||||
ImagePath: config.GetImagePath(),
|
|
||||||
AllowServerless: true,
|
|
||||||
DefaultFlavor: edgeconnect.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
|
|
||||||
ServerlessConfig: struct{}{},
|
|
||||||
DeploymentManifest: manifestContent,
|
|
||||||
DeploymentGenerator: "kubernetes-basic",
|
|
||||||
},
|
},
|
||||||
|
Deployment: config.GetDeploymentType(),
|
||||||
|
ImagePath: config.GetImagePath(),
|
||||||
|
AllowServerless: true,
|
||||||
|
DefaultFlavor: domain.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
|
||||||
|
ServerlessConfig: struct{}{},
|
||||||
|
DeploymentManifest: manifestContent,
|
||||||
|
DeploymentGenerator: "kubernetes-basic",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add network configuration if specified
|
// Add network configuration if specified
|
||||||
if config.Spec.Network != nil {
|
if config.Spec.Network != nil {
|
||||||
appInput.App.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network)
|
app.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the application (recreate strategy always creates from scratch)
|
// Create the application (recreate strategy always creates from scratch)
|
||||||
if err := r.client.CreateApp(ctx, appInput); err != nil {
|
if err := r.appRepo.CreateApp(ctx, action.Desired.Region, app); err != nil {
|
||||||
return false, fmt.Errorf("failed to create application: %w", err)
|
return false, fmt.Errorf("failed to create application: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -516,33 +497,27 @@ func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppActi
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convertNetworkRules converts config.NetworkConfig to []domain.SecurityRule
|
||||||
|
func convertNetworkRules(network *config.NetworkConfig) []domain.SecurityRule {
|
||||||
|
if network == nil || len(network.OutboundConnections) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rules := make([]domain.SecurityRule, len(network.OutboundConnections))
|
||||||
|
for i, conn := range network.OutboundConnections {
|
||||||
|
rules[i] = domain.SecurityRule{
|
||||||
|
Protocol: conn.Protocol,
|
||||||
|
PortRangeMin: conn.PortRangeMin,
|
||||||
|
PortRangeMax: conn.PortRangeMax,
|
||||||
|
RemoteCIDR: conn.RemoteCIDR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
// logf logs a message if a logger is configured
|
// logf logs a message if a logger is configured
|
||||||
func (r *RecreateStrategy) logf(format string, v ...interface{}) {
|
func (r *RecreateStrategy) logf(format string, v ...interface{}) {
|
||||||
if r.logger != nil {
|
if r.logger != nil {
|
||||||
r.logger.Printf("[RecreateStrategy] "+format, v...)
|
r.logger.Printf("[RecreateStrategy] "+format, v...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isRetryableError determines if an error should be retried
|
|
||||||
// Returns false for client errors (4xx), true for server errors (5xx) and other transient errors
|
|
||||||
func isRetryableError(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's an APIError with a status code
|
|
||||||
var apiErr *edgeconnect.APIError
|
|
||||||
if errors.As(err, &apiErr) {
|
|
||||||
// Don't retry client errors (4xx)
|
|
||||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Retry server errors (5xx)
|
|
||||||
if apiErr.StatusCode >= 500 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry all other errors (network issues, timeouts, etc.)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
161
internal/application/apply/types.go
Normal file
161
internal/application/apply/types.go
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
// ABOUTME: Core types for EdgeConnect deployment planning and execution
|
||||||
|
// ABOUTME: Defines data structures for deployment plans, actions, and results
|
||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActionType defines the type of action to be performed
|
||||||
|
type ActionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionNone ActionType = "NONE"
|
||||||
|
ActionCreate ActionType = "CREATE"
|
||||||
|
ActionUpdate ActionType = "UPDATE"
|
||||||
|
ActionDelete ActionType = "DELETE"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppType defines the type of application deployment
|
||||||
|
type AppType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AppTypeK8s AppType = "k8s"
|
||||||
|
AppTypeDocker AppType = "docker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppState represents the desired or current state of an application
|
||||||
|
type AppState struct {
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
Organization string
|
||||||
|
Region string
|
||||||
|
AppType AppType
|
||||||
|
ManifestHash string
|
||||||
|
OutboundConnections []domain.SecurityRule
|
||||||
|
Exists bool
|
||||||
|
LastUpdated time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceState represents the desired or current state of an application instance
|
||||||
|
type InstanceState struct {
|
||||||
|
Name string
|
||||||
|
AppName string
|
||||||
|
AppVersion string
|
||||||
|
Organization string
|
||||||
|
Region string
|
||||||
|
CloudletOrg string
|
||||||
|
CloudletName string
|
||||||
|
FlavorName string
|
||||||
|
State string
|
||||||
|
PowerState string
|
||||||
|
Exists bool
|
||||||
|
LastUpdated time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppAction defines an action to be performed on an application
|
||||||
|
type AppAction struct {
|
||||||
|
Type ActionType
|
||||||
|
Desired *AppState
|
||||||
|
Current *AppState
|
||||||
|
ManifestHash string
|
||||||
|
ManifestChanged bool
|
||||||
|
Reason string
|
||||||
|
Changes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceAction defines an action to be performed on an application instance
|
||||||
|
type InstanceAction struct {
|
||||||
|
Type ActionType
|
||||||
|
Target config.InfraTemplate
|
||||||
|
Desired *InstanceState
|
||||||
|
Current *InstanceState
|
||||||
|
InstanceName string
|
||||||
|
Reason string
|
||||||
|
Changes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeploymentPlan represents a plan of actions to achieve the desired state
|
||||||
|
type DeploymentPlan struct {
|
||||||
|
ConfigName string
|
||||||
|
CreatedAt time.Time
|
||||||
|
DryRun bool
|
||||||
|
AppAction AppAction
|
||||||
|
InstanceActions []InstanceAction
|
||||||
|
TotalActions int
|
||||||
|
EstimatedDuration time.Duration
|
||||||
|
Summary string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true if the plan contains no actions
|
||||||
|
func (p *DeploymentPlan) IsEmpty() bool {
|
||||||
|
return p.AppAction.Type == ActionNone && len(p.InstanceActions) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks the validity of the deployment plan
|
||||||
|
func (p *DeploymentPlan) Validate() error {
|
||||||
|
if p.AppAction.Type == ActionNone && len(p.InstanceActions) == 0 {
|
||||||
|
return fmt.Errorf("deployment plan is empty")
|
||||||
|
}
|
||||||
|
// Add more validation rules as needed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSummary creates a human-readable summary of the plan
|
||||||
|
func (p *DeploymentPlan) GenerateSummary() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Plan for '%s' (created: %s, dry-run: %t)\n", p.ConfigName, p.CreatedAt.Format(time.RFC3339), p.DryRun))
|
||||||
|
sb.WriteString("--------------------------------------------------\n")
|
||||||
|
|
||||||
|
if p.AppAction.Type != ActionNone {
|
||||||
|
sb.WriteString(fmt.Sprintf("Application '%s': %s - %s\n", p.AppAction.Desired.Name, p.AppAction.Type, p.AppAction.Reason))
|
||||||
|
for _, change := range p.AppAction.Changes {
|
||||||
|
sb.WriteString(fmt.Sprintf(" - %s\n", change))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, action := range p.InstanceActions {
|
||||||
|
sb.WriteString(fmt.Sprintf("Instance '%s' on '%s': %s - %s\n", action.InstanceName, action.Target.CloudletName, action.Type, action.Reason))
|
||||||
|
for _, change := range action.Changes {
|
||||||
|
sb.WriteString(fmt.Sprintf(" - %s\n", change))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("--------------------------------------------------\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("Total actions: %d, Estimated duration: %v\n", p.TotalActions, p.EstimatedDuration))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanResult holds the result of a planning operation
|
||||||
|
type PlanResult struct {
|
||||||
|
Plan *DeploymentPlan
|
||||||
|
Warnings []string
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionResult holds the result of a deployment execution
|
||||||
|
type ExecutionResult struct {
|
||||||
|
Plan *DeploymentPlan
|
||||||
|
Success bool
|
||||||
|
Error error
|
||||||
|
Duration time.Duration
|
||||||
|
CompletedActions []ActionResult
|
||||||
|
FailedActions []ActionResult
|
||||||
|
RollbackPerformed bool
|
||||||
|
RollbackSuccess bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionResult details the outcome of a single action
|
||||||
|
type ActionResult struct {
|
||||||
|
Type ActionType
|
||||||
|
Target string
|
||||||
|
Success bool
|
||||||
|
Error error
|
||||||
|
Details string
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
118
internal/application/cloudlet/service.go
Normal file
118
internal/application/cloudlet/service.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
package cloudlet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving"
|
||||||
|
)
|
||||||
|
|
||||||
|
type service struct {
|
||||||
|
cloudletRepo driven.CloudletRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(cloudletRepo driven.CloudletRepository) driving.CloudletService {
|
||||||
|
return &service{cloudletRepo: cloudletRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error {
|
||||||
|
if err := s.validateCloudlet(cloudlet); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "" {
|
||||||
|
return domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.cloudletRepo.CreateCloudlet(ctx, region, cloudlet); err != nil {
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
return domain.NewCloudletError(domain.ErrResourceConflict, "CreateCloudlet", cloudlet.Key, region,
|
||||||
|
"cloudlet may already exist or have conflicting configuration")
|
||||||
|
}
|
||||||
|
return domain.NewCloudletError(domain.ErrInternalError, "CreateCloudlet", cloudlet.Key, region,
|
||||||
|
"failed to create cloudlet").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) {
|
||||||
|
if err := s.validateCloudletKey(cloudletKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "" {
|
||||||
|
return nil, domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
cloudlet, err := s.cloudletRepo.ShowCloudlet(ctx, region, cloudletKey)
|
||||||
|
if err != nil {
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region,
|
||||||
|
"cloudlet does not exist")
|
||||||
|
}
|
||||||
|
return nil, domain.NewCloudletError(domain.ErrInternalError, "ShowCloudlet", cloudletKey, region,
|
||||||
|
"failed to retrieve cloudlet").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloudlet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) {
|
||||||
|
if region == "" {
|
||||||
|
return nil, domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
cloudlets, err := s.cloudletRepo.ShowCloudlets(ctx, region, cloudletKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, domain.NewCloudletError(domain.ErrInternalError, "ShowCloudlets", cloudletKey, region,
|
||||||
|
"failed to list cloudlets").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloudlets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error {
|
||||||
|
if err := s.validateCloudletKey(cloudletKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "" {
|
||||||
|
return domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.cloudletRepo.DeleteCloudlet(ctx, region, cloudletKey); err != nil {
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
return domain.NewCloudletError(domain.ErrResourceNotFound, "DeleteCloudlet", cloudletKey, region,
|
||||||
|
"cloudlet does not exist")
|
||||||
|
}
|
||||||
|
return domain.NewCloudletError(domain.ErrInternalError, "DeleteCloudlet", cloudletKey, region,
|
||||||
|
"failed to delete cloudlet").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCloudlet performs business logic validation on a cloudlet
|
||||||
|
func (s *service) validateCloudlet(cloudlet *domain.Cloudlet) error {
|
||||||
|
if cloudlet == nil {
|
||||||
|
return domain.NewDomainError(domain.ErrValidationFailed, "cloudlet cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.validateCloudletKey(cloudlet.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCloudletKey performs business logic validation on a cloudlet key
|
||||||
|
func (s *service) validateCloudletKey(cloudletKey domain.CloudletKey) error {
|
||||||
|
if strings.TrimSpace(cloudletKey.Organization) == "" {
|
||||||
|
return domain.ErrInvalidCloudletKey.WithDetails("organization is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(cloudletKey.Name) == "" {
|
||||||
|
return domain.ErrInvalidCloudletKey.WithDetails("name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
185
internal/application/instance/service.go
Normal file
185
internal/application/instance/service.go
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
package instance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving"
|
||||||
|
)
|
||||||
|
|
||||||
|
type service struct {
|
||||||
|
appInstanceRepo driven.AppInstanceRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(appInstanceRepo driven.AppInstanceRepository) driving.AppInstanceService {
|
||||||
|
return &service{appInstanceRepo: appInstanceRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
|
||||||
|
if err := s.validateAppInstance(appInst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "" {
|
||||||
|
return domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.appInstanceRepo.CreateAppInstance(ctx, region, appInst); err != nil {
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
return domain.NewInstanceError(domain.ErrResourceConflict, "CreateAppInstance", appInst.Key, region,
|
||||||
|
"app instance may already exist or have conflicting configuration")
|
||||||
|
}
|
||||||
|
return domain.NewInstanceError(domain.ErrInternalError, "CreateAppInstance", appInst.Key, region,
|
||||||
|
"failed to create app instance").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) {
|
||||||
|
if err := s.validateAppInstanceKey(appInstKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "" {
|
||||||
|
return nil, domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
instance, err := s.appInstanceRepo.ShowAppInstance(ctx, region, appInstKey)
|
||||||
|
if err != nil {
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region,
|
||||||
|
"app instance does not exist")
|
||||||
|
}
|
||||||
|
return nil, domain.NewInstanceError(domain.ErrInternalError, "ShowAppInstance", appInstKey, region,
|
||||||
|
"failed to retrieve app instance").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) {
|
||||||
|
if region == "" {
|
||||||
|
return nil, domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
instances, err := s.appInstanceRepo.ShowAppInstances(ctx, region, appInstKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, domain.NewInstanceError(domain.ErrInternalError, "ShowAppInstances", appInstKey, region,
|
||||||
|
"failed to list app instances").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
|
||||||
|
if err := s.validateAppInstanceKey(appInstKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "" {
|
||||||
|
return domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.appInstanceRepo.DeleteAppInstance(ctx, region, appInstKey); err != nil {
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
return domain.NewInstanceError(domain.ErrResourceNotFound, "DeleteAppInstance", appInstKey, region,
|
||||||
|
"app instance does not exist")
|
||||||
|
}
|
||||||
|
return domain.NewInstanceError(domain.ErrInternalError, "DeleteAppInstance", appInstKey, region,
|
||||||
|
"failed to delete app instance").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error {
|
||||||
|
if err := s.validateAppInstance(appInst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "" {
|
||||||
|
return domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.appInstanceRepo.UpdateAppInstance(ctx, region, appInst); err != nil {
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
return domain.NewInstanceError(domain.ErrResourceConflict, "UpdateAppInstance", appInst.Key, region,
|
||||||
|
"app instance may already exist or have conflicting configuration")
|
||||||
|
}
|
||||||
|
return domain.NewInstanceError(domain.ErrInternalError, "UpdateAppInstance", appInst.Key, region,
|
||||||
|
"failed to update app instance").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error {
|
||||||
|
if err := s.validateAppInstanceKey(appInstKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "" {
|
||||||
|
return domain.ErrMissingRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: The driven port (repository) does not currently have a Refresh method.
|
||||||
|
// This is a placeholder implementation.
|
||||||
|
// To fully implement this, we would need to add RefreshAppInstance to the AppInstanceRepository interface
|
||||||
|
// and implement it in the edgeconnect adapter.
|
||||||
|
// For now, we can just return nil or a 'not implemented' error.
|
||||||
|
// Let's delegate to the Show method as a temporary measure.
|
||||||
|
_, err := s.appInstanceRepo.ShowAppInstance(ctx, region, appInstKey)
|
||||||
|
if err != nil {
|
||||||
|
if domain.IsNotFoundError(err) {
|
||||||
|
return domain.NewInstanceError(domain.ErrResourceNotFound, "RefreshAppInstance", appInstKey, region,
|
||||||
|
"app instance does not exist")
|
||||||
|
}
|
||||||
|
return domain.NewInstanceError(domain.ErrInternalError, "RefreshAppInstance", appInstKey, region,
|
||||||
|
"failed to refresh app instance").WithDetails(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAppInstance performs business logic validation on an app instance
|
||||||
|
func (s *service) validateAppInstance(appInst *domain.AppInstance) error {
|
||||||
|
if appInst == nil {
|
||||||
|
return domain.NewDomainError(domain.ErrValidationFailed, "app instance cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.validateAppInstanceKey(appInst.Key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate flavor if present
|
||||||
|
if strings.TrimSpace(appInst.Flavor.Name) == "" {
|
||||||
|
return domain.NewDomainError(domain.ErrValidationFailed, "flavor name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAppInstanceKey performs business logic validation on an app instance key
|
||||||
|
func (s *service) validateAppInstanceKey(appInstKey domain.AppInstanceKey) error {
|
||||||
|
if strings.TrimSpace(appInstKey.Organization) == "" {
|
||||||
|
return domain.ErrInvalidInstanceKey.WithDetails("organization is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(appInstKey.Name) == "" {
|
||||||
|
return domain.ErrInvalidInstanceKey.WithDetails("name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate embedded cloudlet key
|
||||||
|
if strings.TrimSpace(appInstKey.CloudletKey.Organization) == "" {
|
||||||
|
return domain.ErrInvalidInstanceKey.WithDetails("cloudlet organization is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(appInstKey.CloudletKey.Name) == "" {
|
||||||
|
return domain.ErrInvalidInstanceKey.WithDetails("cloudlet name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
63
internal/application/organization/service.go
Normal file
63
internal/application/organization/service.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package organization
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driven"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/ports/driving"
|
||||||
|
)
|
||||||
|
|
||||||
|
// service implements the OrganizationService interface and provides the core business logic.
|
||||||
|
type service struct {
|
||||||
|
repo driven.OrganizationRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new organization service with the given repository.
|
||||||
|
func NewService(repo driven.OrganizationRepository) driving.OrganizationService {
|
||||||
|
return &service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrganization validates the organization and passes it to the repository for creation.
|
||||||
|
func (s *service) Create(ctx context.Context, org *domain.Organization) error {
|
||||||
|
if err := s.validateOrganization(org); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.repo.CreateOrganization(ctx, org)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves an organization by name.
|
||||||
|
func (s *service) Get(ctx context.Context, name string) (*domain.Organization, error) {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return nil, domain.NewDomainError(domain.ErrValidationFailed, "organization name cannot be empty")
|
||||||
|
}
|
||||||
|
return s.repo.ShowOrganization(ctx, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update validates the organization and passes it to the repository for updates.
|
||||||
|
func (s *service) Update(ctx context.Context, org *domain.Organization) error {
|
||||||
|
if err := s.validateOrganization(org); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.repo.UpdateOrganization(ctx, org)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes an organization by name.
|
||||||
|
func (s *service) Delete(ctx context.Context, name string) error {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return domain.NewDomainError(domain.ErrValidationFailed, "organization name cannot be empty")
|
||||||
|
}
|
||||||
|
return s.repo.DeleteOrganization(ctx, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateOrganization contains the business logic for validating an organization.
|
||||||
|
func (s *service) validateOrganization(org *domain.Organization) error {
|
||||||
|
if org == nil {
|
||||||
|
return domain.NewDomainError(domain.ErrValidationFailed, "organization cannot be nil")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(org.Name) == "" {
|
||||||
|
return domain.NewDomainError(domain.ErrValidationFailed, "organization name cannot be empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -1,557 +0,0 @@
|
||||||
// ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison
|
|
||||||
// ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls
|
|
||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EdgeConnectClientInterface defines the methods needed for deployment planning
|
|
||||||
type EdgeConnectClientInterface interface {
|
|
||||||
ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error)
|
|
||||||
CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error
|
|
||||||
UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error
|
|
||||||
DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error
|
|
||||||
ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error)
|
|
||||||
CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error
|
|
||||||
UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error
|
|
||||||
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Planner defines the interface for deployment planning
|
|
||||||
type Planner interface {
|
|
||||||
// Plan analyzes the configuration and current state to generate a deployment plan
|
|
||||||
Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error)
|
|
||||||
|
|
||||||
// PlanWithOptions allows customization of planning behavior
|
|
||||||
PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanOptions provides configuration for the planning process
|
|
||||||
type PlanOptions struct {
|
|
||||||
// DryRun indicates this is a planning-only operation
|
|
||||||
DryRun bool
|
|
||||||
|
|
||||||
// Force indicates to proceed even with warnings
|
|
||||||
Force bool
|
|
||||||
|
|
||||||
// SkipStateCheck bypasses current state queries (useful for testing)
|
|
||||||
SkipStateCheck bool
|
|
||||||
|
|
||||||
// ParallelQueries enables parallel state fetching
|
|
||||||
ParallelQueries bool
|
|
||||||
|
|
||||||
// Timeout for API operations
|
|
||||||
Timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultPlanOptions returns sensible default planning options
|
|
||||||
func DefaultPlanOptions() PlanOptions {
|
|
||||||
return PlanOptions{
|
|
||||||
DryRun: false,
|
|
||||||
Force: false,
|
|
||||||
SkipStateCheck: false,
|
|
||||||
ParallelQueries: true,
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
|
|
||||||
type EdgeConnectPlanner struct {
|
|
||||||
client EdgeConnectClientInterface
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPlanner creates a new EdgeConnect deployment planner
|
|
||||||
func NewPlanner(client EdgeConnectClientInterface) Planner {
|
|
||||||
return &EdgeConnectPlanner{
|
|
||||||
client: client,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan analyzes the configuration and generates a deployment plan
|
|
||||||
func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) {
|
|
||||||
return p.PlanWithOptions(ctx, config, DefaultPlanOptions())
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanWithOptions generates a deployment plan with custom options
|
|
||||||
func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) {
|
|
||||||
startTime := time.Now()
|
|
||||||
var warnings []string
|
|
||||||
|
|
||||||
// Create the deployment plan structure
|
|
||||||
plan := &DeploymentPlan{
|
|
||||||
ConfigName: config.Metadata.Name,
|
|
||||||
CreatedAt: startTime,
|
|
||||||
DryRun: opts.DryRun,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Plan application state
|
|
||||||
appAction, appWarnings, err := p.planAppAction(ctx, config, opts)
|
|
||||||
if err != nil {
|
|
||||||
return &PlanResult{Error: err}, err
|
|
||||||
}
|
|
||||||
plan.AppAction = *appAction
|
|
||||||
warnings = append(warnings, appWarnings...)
|
|
||||||
|
|
||||||
// Step 2: Plan instance actions
|
|
||||||
instanceActions, instanceWarnings, err := p.planInstanceActions(ctx, config, opts)
|
|
||||||
if err != nil {
|
|
||||||
return &PlanResult{Error: err}, err
|
|
||||||
}
|
|
||||||
plan.InstanceActions = instanceActions
|
|
||||||
warnings = append(warnings, instanceWarnings...)
|
|
||||||
|
|
||||||
// Step 3: Calculate plan metadata
|
|
||||||
p.calculatePlanMetadata(plan)
|
|
||||||
|
|
||||||
// Step 4: Generate summary
|
|
||||||
plan.Summary = plan.GenerateSummary()
|
|
||||||
|
|
||||||
// Step 5: Validate the plan
|
|
||||||
if err := plan.Validate(); err != nil {
|
|
||||||
return &PlanResult{Error: fmt.Errorf("invalid deployment plan: %w", err)}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PlanResult{
|
|
||||||
Plan: plan,
|
|
||||||
Warnings: warnings,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// planAppAction determines what action needs to be taken for the application
|
|
||||||
func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*AppAction, []string, error) {
|
|
||||||
var warnings []string
|
|
||||||
|
|
||||||
// Build desired app state
|
|
||||||
desired := &AppState{
|
|
||||||
Name: config.Metadata.Name,
|
|
||||||
Version: config.Metadata.AppVersion,
|
|
||||||
Organization: config.Metadata.Organization, // Use first infra template for org
|
|
||||||
Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region
|
|
||||||
Exists: false, // Will be set based on current state
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.Spec.IsK8sApp() {
|
|
||||||
desired.AppType = AppTypeK8s
|
|
||||||
} else {
|
|
||||||
desired.AppType = AppTypeDocker
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract outbound connections from config
|
|
||||||
if config.Spec.Network != nil {
|
|
||||||
desired.OutboundConnections = make([]SecurityRule, len(config.Spec.Network.OutboundConnections))
|
|
||||||
for i, conn := range config.Spec.Network.OutboundConnections {
|
|
||||||
desired.OutboundConnections[i] = SecurityRule{
|
|
||||||
Protocol: conn.Protocol,
|
|
||||||
PortRangeMin: conn.PortRangeMin,
|
|
||||||
PortRangeMax: conn.PortRangeMax,
|
|
||||||
RemoteCIDR: conn.RemoteCIDR,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate manifest hash
|
|
||||||
manifestHash, err := p.calculateManifestHash(config.Spec.GetManifestFile())
|
|
||||||
if err != nil {
|
|
||||||
return nil, warnings, fmt.Errorf("failed to calculate manifest hash: %w", err)
|
|
||||||
}
|
|
||||||
desired.ManifestHash = manifestHash
|
|
||||||
|
|
||||||
action := &AppAction{
|
|
||||||
Type: ActionNone,
|
|
||||||
Desired: desired,
|
|
||||||
ManifestHash: manifestHash,
|
|
||||||
Reason: "No action needed",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip state check if requested (useful for testing)
|
|
||||||
if opts.SkipStateCheck {
|
|
||||||
action.Type = ActionCreate
|
|
||||||
action.Reason = "Creating app (state check skipped)"
|
|
||||||
action.Changes = []string{"Create new application"}
|
|
||||||
return action, warnings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query current app state
|
|
||||||
current, err := p.getCurrentAppState(ctx, desired, opts.Timeout)
|
|
||||||
if err != nil {
|
|
||||||
// If app doesn't exist, we need to create it
|
|
||||||
if isResourceNotFoundError(err) {
|
|
||||||
action.Type = ActionCreate
|
|
||||||
action.Reason = "Application does not exist"
|
|
||||||
action.Changes = []string{"Create new application"}
|
|
||||||
return action, warnings, nil
|
|
||||||
}
|
|
||||||
return nil, warnings, fmt.Errorf("failed to query current app state: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
action.Current = current
|
|
||||||
|
|
||||||
// Compare current vs desired state
|
|
||||||
changes, manifestChanged := p.compareAppStates(current, desired)
|
|
||||||
action.ManifestChanged = manifestChanged
|
|
||||||
|
|
||||||
if len(changes) > 0 {
|
|
||||||
action.Type = ActionUpdate
|
|
||||||
action.Changes = changes
|
|
||||||
action.Reason = "Application configuration has changed"
|
|
||||||
fmt.Printf("Changes: %v\n", changes)
|
|
||||||
|
|
||||||
if manifestChanged {
|
|
||||||
warnings = append(warnings, "Manifest file has changed - instances may need to be recreated")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return action, warnings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// planInstanceActions determines what actions need to be taken for instances
|
|
||||||
func (p *EdgeConnectPlanner) planInstanceActions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) ([]InstanceAction, []string, error) {
|
|
||||||
var actions []InstanceAction
|
|
||||||
var warnings []string
|
|
||||||
|
|
||||||
for _, infra := range config.Spec.InfraTemplate {
|
|
||||||
instanceName := getInstanceName(config.Metadata.Name, config.Metadata.AppVersion)
|
|
||||||
|
|
||||||
desired := &InstanceState{
|
|
||||||
Name: instanceName,
|
|
||||||
AppVersion: config.Metadata.AppVersion,
|
|
||||||
Organization: config.Metadata.Organization,
|
|
||||||
Region: infra.Region,
|
|
||||||
CloudletOrg: infra.CloudletOrg,
|
|
||||||
CloudletName: infra.CloudletName,
|
|
||||||
FlavorName: infra.FlavorName,
|
|
||||||
Exists: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
action := &InstanceAction{
|
|
||||||
Type: ActionNone,
|
|
||||||
Target: infra,
|
|
||||||
Desired: desired,
|
|
||||||
InstanceName: instanceName,
|
|
||||||
Reason: "No action needed",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip state check if requested
|
|
||||||
if opts.SkipStateCheck {
|
|
||||||
action.Type = ActionCreate
|
|
||||||
action.Reason = "Creating instance (state check skipped)"
|
|
||||||
action.Changes = []string{"Create new instance"}
|
|
||||||
actions = append(actions, *action)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query current instance state
|
|
||||||
current, err := p.getCurrentInstanceState(ctx, desired, opts.Timeout)
|
|
||||||
if err != nil {
|
|
||||||
// If instance doesn't exist, we need to create it
|
|
||||||
if isResourceNotFoundError(err) {
|
|
||||||
action.Type = ActionCreate
|
|
||||||
action.Reason = "Instance does not exist"
|
|
||||||
action.Changes = []string{"Create new instance"}
|
|
||||||
actions = append(actions, *action)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, warnings, fmt.Errorf("failed to query current instance state: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
action.Current = current
|
|
||||||
|
|
||||||
// Compare current vs desired state
|
|
||||||
changes := p.compareInstanceStates(current, desired)
|
|
||||||
if len(changes) > 0 {
|
|
||||||
action.Type = ActionUpdate
|
|
||||||
action.Changes = changes
|
|
||||||
action.Reason = "Instance configuration has changed"
|
|
||||||
}
|
|
||||||
|
|
||||||
actions = append(actions, *action)
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions, warnings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCurrentAppState queries the current state of an application
|
|
||||||
func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *AppState, timeout time.Duration) (*AppState, error) {
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
appKey := edgeconnect.AppKey{
|
|
||||||
Organization: desired.Organization,
|
|
||||||
Name: desired.Name,
|
|
||||||
Version: desired.Version,
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := p.client.ShowApp(timeoutCtx, appKey, desired.Region)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
current := &AppState{
|
|
||||||
Name: app.Key.Name,
|
|
||||||
Version: app.Key.Version,
|
|
||||||
Organization: app.Key.Organization,
|
|
||||||
Region: desired.Region,
|
|
||||||
Exists: true,
|
|
||||||
LastUpdated: time.Now(), // EdgeConnect doesn't provide this, so use current time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate current manifest hash
|
|
||||||
hasher := sha256.New()
|
|
||||||
hasher.Write([]byte(app.DeploymentManifest))
|
|
||||||
current.ManifestHash = fmt.Sprintf("%x", hasher.Sum(nil))
|
|
||||||
|
|
||||||
// Note: EdgeConnect API doesn't currently support annotations for manifest hash tracking
|
|
||||||
// This would be implemented when the API supports it
|
|
||||||
|
|
||||||
// Determine app type based on deployment type
|
|
||||||
if app.Deployment == "kubernetes" {
|
|
||||||
current.AppType = AppTypeK8s
|
|
||||||
} else {
|
|
||||||
current.AppType = AppTypeDocker
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract outbound connections from the app
|
|
||||||
current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections))
|
|
||||||
for i, conn := range app.RequiredOutboundConnections {
|
|
||||||
current.OutboundConnections[i] = SecurityRule(conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
return current, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCurrentInstanceState queries the current state of an application instance
|
|
||||||
func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desired *InstanceState, timeout time.Duration) (*InstanceState, error) {
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
instanceKey := edgeconnect.AppInstanceKey{
|
|
||||||
Organization: desired.Organization,
|
|
||||||
Name: desired.Name,
|
|
||||||
CloudletKey: edgeconnect.CloudletKey{
|
|
||||||
Organization: desired.CloudletOrg,
|
|
||||||
Name: desired.CloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
appKey := edgeconnect.AppKey{
|
|
||||||
Name: desired.AppName,
|
|
||||||
}
|
|
||||||
|
|
||||||
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
current := &InstanceState{
|
|
||||||
Name: instance.Key.Name,
|
|
||||||
AppName: instance.AppKey.Name,
|
|
||||||
AppVersion: instance.AppKey.Version,
|
|
||||||
Organization: instance.Key.Organization,
|
|
||||||
Region: desired.Region,
|
|
||||||
CloudletOrg: instance.Key.CloudletKey.Organization,
|
|
||||||
CloudletName: instance.Key.CloudletKey.Name,
|
|
||||||
FlavorName: instance.Flavor.Name,
|
|
||||||
State: instance.State,
|
|
||||||
PowerState: instance.PowerState,
|
|
||||||
Exists: true,
|
|
||||||
LastUpdated: time.Now(), // EdgeConnect doesn't provide this
|
|
||||||
}
|
|
||||||
|
|
||||||
return current, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// compareAppStates compares current and desired app states and returns changes
|
|
||||||
func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]string, bool) {
|
|
||||||
var changes []string
|
|
||||||
manifestChanged := false
|
|
||||||
|
|
||||||
// Compare manifest hash - only if both states have hash values
|
|
||||||
// Since EdgeConnect API doesn't support annotations yet, skip manifest hash comparison for now
|
|
||||||
// This would be implemented when the API supports manifest hash tracking
|
|
||||||
if current.ManifestHash != "" && desired.ManifestHash != "" && current.ManifestHash != desired.ManifestHash {
|
|
||||||
changes = append(changes, fmt.Sprintf("Manifest hash changed: %s -> %s", current.ManifestHash, desired.ManifestHash))
|
|
||||||
manifestChanged = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare app type
|
|
||||||
if current.AppType != desired.AppType {
|
|
||||||
changes = append(changes, fmt.Sprintf("App type changed: %s -> %s", current.AppType, desired.AppType))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare outbound connections
|
|
||||||
outboundChanges := p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections)
|
|
||||||
if len(outboundChanges) > 0 {
|
|
||||||
sb := strings.Builder{}
|
|
||||||
sb.WriteString("Outbound connections changed:\n")
|
|
||||||
for _, change := range outboundChanges {
|
|
||||||
sb.WriteString(change)
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
changes = append(changes, sb.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
return changes, manifestChanged
|
|
||||||
}
|
|
||||||
|
|
||||||
// compareOutboundConnections compares two sets of outbound connections for equality
|
|
||||||
func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) []string {
|
|
||||||
var changes []string
|
|
||||||
makeMap := func(rules []SecurityRule) map[string]SecurityRule {
|
|
||||||
m := make(map[string]SecurityRule, len(rules))
|
|
||||||
for _, r := range rules {
|
|
||||||
key := fmt.Sprintf("%s:%d-%d:%s",
|
|
||||||
strings.ToLower(r.Protocol),
|
|
||||||
r.PortRangeMin,
|
|
||||||
r.PortRangeMax,
|
|
||||||
r.RemoteCIDR,
|
|
||||||
)
|
|
||||||
m[key] = r
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
currentMap := makeMap(current)
|
|
||||||
desiredMap := makeMap(desired)
|
|
||||||
|
|
||||||
// Find added and modified rules
|
|
||||||
for key, rule := range desiredMap {
|
|
||||||
if _, exists := currentMap[key]; !exists {
|
|
||||||
changes = append(changes, fmt.Sprintf(" - Added outbound connection: %s %d-%d to %s", rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteCIDR))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find removed rules
|
|
||||||
for key, rule := range currentMap {
|
|
||||||
if _, exists := desiredMap[key]; !exists {
|
|
||||||
changes = append(changes, fmt.Sprintf(" - Removed outbound connection: %s %d-%d to %s", rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteCIDR))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changes
|
|
||||||
}
|
|
||||||
|
|
||||||
// compareInstanceStates compares current and desired instance states and returns changes
|
|
||||||
func (p *EdgeConnectPlanner) compareInstanceStates(current, desired *InstanceState) []string {
|
|
||||||
var changes []string
|
|
||||||
|
|
||||||
if current.FlavorName != desired.FlavorName {
|
|
||||||
changes = append(changes, fmt.Sprintf("Flavor changed: %s -> %s", current.FlavorName, desired.FlavorName))
|
|
||||||
}
|
|
||||||
|
|
||||||
if current.CloudletName != desired.CloudletName {
|
|
||||||
changes = append(changes, fmt.Sprintf("Cloudlet changed: %s -> %s", current.CloudletName, desired.CloudletName))
|
|
||||||
}
|
|
||||||
|
|
||||||
if current.CloudletOrg != desired.CloudletOrg {
|
|
||||||
changes = append(changes, fmt.Sprintf("Cloudlet org changed: %s -> %s", current.CloudletOrg, desired.CloudletOrg))
|
|
||||||
}
|
|
||||||
|
|
||||||
return changes
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateManifestHash computes the SHA256 hash of a manifest file
|
|
||||||
func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string, error) {
|
|
||||||
if manifestPath == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(manifestPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to open manifest file: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = file.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
hasher := sha256.New()
|
|
||||||
if _, err := io.Copy(hasher, file); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to hash manifest file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculatePlanMetadata computes metadata for the deployment plan
|
|
||||||
func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeploymentPlan) {
|
|
||||||
totalActions := 0
|
|
||||||
|
|
||||||
if plan.AppAction.Type != ActionNone {
|
|
||||||
totalActions++
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, action := range plan.InstanceActions {
|
|
||||||
if action.Type != ActionNone {
|
|
||||||
totalActions++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
plan.TotalActions = totalActions
|
|
||||||
|
|
||||||
// Estimate duration based on action types and counts
|
|
||||||
plan.EstimatedDuration = p.estimateDeploymentDuration(plan)
|
|
||||||
}
|
|
||||||
|
|
||||||
// estimateDeploymentDuration provides a rough estimate of deployment time
|
|
||||||
func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) time.Duration {
|
|
||||||
var duration time.Duration
|
|
||||||
|
|
||||||
// App operations
|
|
||||||
switch plan.AppAction.Type {
|
|
||||||
case ActionCreate:
|
|
||||||
duration += 30 * time.Second
|
|
||||||
case ActionUpdate:
|
|
||||||
duration += 15 * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instance operations (can be done in parallel)
|
|
||||||
instanceDuration := time.Duration(0)
|
|
||||||
for _, action := range plan.InstanceActions {
|
|
||||||
switch action.Type {
|
|
||||||
case ActionCreate:
|
|
||||||
instanceDuration = max(instanceDuration, 2*time.Minute)
|
|
||||||
case ActionUpdate:
|
|
||||||
instanceDuration = max(instanceDuration, 1*time.Minute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
duration += instanceDuration
|
|
||||||
|
|
||||||
// Add buffer time
|
|
||||||
duration += 30 * time.Second
|
|
||||||
|
|
||||||
return duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// isResourceNotFoundError checks if an error indicates a resource was not found
|
|
||||||
func isResourceNotFoundError(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
errStr := strings.ToLower(err.Error())
|
|
||||||
return strings.Contains(errStr, "not found") ||
|
|
||||||
strings.Contains(errStr, "does not exist") ||
|
|
||||||
strings.Contains(errStr, "404")
|
|
||||||
}
|
|
||||||
|
|
||||||
// max returns the larger of two durations
|
|
||||||
func max(a, b time.Duration) time.Duration {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInstanceName generates the instance name following the pattern: appName-appVersion-instance
|
|
||||||
func getInstanceName(appName, appVersion string) string {
|
|
||||||
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
|
|
||||||
}
|
|
||||||
|
|
@ -1,655 +0,0 @@
|
||||||
// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios
|
|
||||||
// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios
|
|
||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockEdgeConnectClient is a mock implementation of the EdgeConnect client
|
|
||||||
type MockEdgeConnectClient struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error) {
|
|
||||||
args := m.Called(ctx, appKey, region)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return edgeconnect.App{}, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(edgeconnect.App), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error) {
|
|
||||||
args := m.Called(ctx, instanceKey, region)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return edgeconnect.AppInstance{}, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(edgeconnect.AppInstance), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error {
|
|
||||||
args := m.Called(ctx, input)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error {
|
|
||||||
args := m.Called(ctx, input)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error {
|
|
||||||
args := m.Called(ctx, appKey, region)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error {
|
|
||||||
args := m.Called(ctx, input)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error {
|
|
||||||
args := m.Called(ctx, input)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error {
|
|
||||||
args := m.Called(ctx, instanceKey, region)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect.AppKey, region string) ([]edgeconnect.App, error) {
|
|
||||||
args := m.Called(ctx, appKey, region)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).([]edgeconnect.App), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewPlanner(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
|
|
||||||
assert.NotNil(t, planner)
|
|
||||||
assert.IsType(t, &EdgeConnectPlanner{}, planner)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultPlanOptions(t *testing.T) {
|
|
||||||
opts := DefaultPlanOptions()
|
|
||||||
|
|
||||||
assert.False(t, opts.DryRun)
|
|
||||||
assert.False(t, opts.Force)
|
|
||||||
assert.False(t, opts.SkipStateCheck)
|
|
||||||
assert.True(t, opts.ParallelQueries)
|
|
||||||
assert.Equal(t, 30*time.Second, opts.Timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTestConfig(t *testing.T) *config.EdgeConnectConfig {
|
|
||||||
// Create temporary manifest file
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
|
|
||||||
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
|
|
||||||
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return &config.EdgeConnectConfig{
|
|
||||||
Kind: "edgeconnect-deployment",
|
|
||||||
Metadata: config.Metadata{
|
|
||||||
Name: "test-app",
|
|
||||||
AppVersion: "1.0.0",
|
|
||||||
Organization: "testorg",
|
|
||||||
},
|
|
||||||
Spec: config.Spec{
|
|
||||||
K8sApp: &config.K8sApp{
|
|
||||||
ManifestFile: manifestFile,
|
|
||||||
},
|
|
||||||
InfraTemplate: []config.InfraTemplate{
|
|
||||||
{
|
|
||||||
Region: "US",
|
|
||||||
CloudletOrg: "TestCloudletOrg",
|
|
||||||
CloudletName: "TestCloudlet",
|
|
||||||
FlavorName: "small",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Network: &config.NetworkConfig{
|
|
||||||
OutboundConnections: []config.OutboundConnection{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanNewDeployment(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
// Mock API calls to return "not found" errors
|
|
||||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
|
|
||||||
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
|
||||||
|
|
||||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US").
|
|
||||||
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.Plan(ctx, testConfig)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.NotNil(t, result.Plan)
|
|
||||||
require.NoError(t, result.Error)
|
|
||||||
|
|
||||||
plan := result.Plan
|
|
||||||
assert.Equal(t, "test-app", plan.ConfigName)
|
|
||||||
assert.Equal(t, ActionCreate, plan.AppAction.Type)
|
|
||||||
assert.Equal(t, "Application does not exist", plan.AppAction.Reason)
|
|
||||||
|
|
||||||
require.Len(t, plan.InstanceActions, 1)
|
|
||||||
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
|
|
||||||
assert.Equal(t, "Instance does not exist", plan.InstanceActions[0].Reason)
|
|
||||||
|
|
||||||
assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance
|
|
||||||
assert.False(t, plan.IsEmpty())
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanExistingDeploymentNoChanges(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
// Note: We would calculate expected manifest hash here when API supports it
|
|
||||||
|
|
||||||
// Mock existing app with same manifest hash and outbound connections
|
|
||||||
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
|
|
||||||
existingApp := &edgeconnect.App{
|
|
||||||
Key: edgeconnect.AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Deployment: "kubernetes",
|
|
||||||
DeploymentManifest: manifestContent,
|
|
||||||
RequiredOutboundConnections: []edgeconnect.SecurityRule{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Note: Manifest hash tracking would be implemented when API supports annotations
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock existing instance
|
|
||||||
existingInstance := &edgeconnect.AppInstance{
|
|
||||||
Key: edgeconnect.AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "test-app-1.0.0-instance",
|
|
||||||
CloudletKey: edgeconnect.CloudletKey{
|
|
||||||
Organization: "TestCloudletOrg",
|
|
||||||
Name: "TestCloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppKey: edgeconnect.AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Flavor: edgeconnect.Flavor{
|
|
||||||
Name: "small",
|
|
||||||
},
|
|
||||||
State: "Ready",
|
|
||||||
PowerState: "PowerOn",
|
|
||||||
}
|
|
||||||
|
|
||||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
|
|
||||||
Return(*existingApp, nil)
|
|
||||||
|
|
||||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US").
|
|
||||||
Return(*existingInstance, nil)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.Plan(ctx, testConfig)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.NotNil(t, result.Plan)
|
|
||||||
|
|
||||||
plan := result.Plan
|
|
||||||
assert.Equal(t, ActionNone, plan.AppAction.Type)
|
|
||||||
assert.Len(t, plan.InstanceActions, 1)
|
|
||||||
assert.Equal(t, ActionNone, plan.InstanceActions[0].Type)
|
|
||||||
assert.Equal(t, 0, plan.TotalActions)
|
|
||||||
assert.True(t, plan.IsEmpty())
|
|
||||||
assert.Contains(t, plan.Summary, "No changes required")
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanWithOptions(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
opts := PlanOptions{
|
|
||||||
DryRun: true,
|
|
||||||
SkipStateCheck: true,
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.PlanWithOptions(ctx, testConfig, opts)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.NotNil(t, result.Plan)
|
|
||||||
|
|
||||||
plan := result.Plan
|
|
||||||
assert.True(t, plan.DryRun)
|
|
||||||
assert.Equal(t, ActionCreate, plan.AppAction.Type)
|
|
||||||
assert.Contains(t, plan.AppAction.Reason, "state check skipped")
|
|
||||||
|
|
||||||
// No API calls should be made when SkipStateCheck is true
|
|
||||||
mockClient.AssertNotCalled(t, "ShowApp")
|
|
||||||
mockClient.AssertNotCalled(t, "ShowAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanMultipleInfrastructures(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
// Add a second infrastructure target
|
|
||||||
testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{
|
|
||||||
Region: "EU",
|
|
||||||
CloudletOrg: "EUCloudletOrg",
|
|
||||||
CloudletName: "EUCloudlet",
|
|
||||||
FlavorName: "medium",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mock API calls to return "not found" errors
|
|
||||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
|
|
||||||
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
|
||||||
|
|
||||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "US").
|
|
||||||
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
|
|
||||||
|
|
||||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("edgeconnect.AppInstanceKey"), "EU").
|
|
||||||
Return(nil, &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.Plan(ctx, testConfig)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.NotNil(t, result.Plan)
|
|
||||||
|
|
||||||
plan := result.Plan
|
|
||||||
assert.Equal(t, ActionCreate, plan.AppAction.Type)
|
|
||||||
|
|
||||||
// Should have 2 instance actions, one for each infrastructure
|
|
||||||
require.Len(t, plan.InstanceActions, 2)
|
|
||||||
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
|
|
||||||
assert.Equal(t, ActionCreate, plan.InstanceActions[1].Type)
|
|
||||||
|
|
||||||
assert.Equal(t, 3, plan.TotalActions) // 1 app + 2 instances
|
|
||||||
|
|
||||||
// Test cloudlet and region aggregation
|
|
||||||
cloudlets := plan.GetTargetCloudlets()
|
|
||||||
regions := plan.GetTargetRegions()
|
|
||||||
assert.Len(t, cloudlets, 2)
|
|
||||||
assert.Len(t, regions, 2)
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculateManifestHash(t *testing.T) {
|
|
||||||
planner := &EdgeConnectPlanner{}
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
// Create test file
|
|
||||||
testFile := filepath.Join(tempDir, "test.yaml")
|
|
||||||
content := "test content for hashing"
|
|
||||||
err := os.WriteFile(testFile, []byte(content), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
hash1, err := planner.calculateManifestHash(testFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotEmpty(t, hash1)
|
|
||||||
assert.Len(t, hash1, 64) // SHA256 hex string length
|
|
||||||
|
|
||||||
// Same content should produce same hash
|
|
||||||
hash2, err := planner.calculateManifestHash(testFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, hash1, hash2)
|
|
||||||
|
|
||||||
// Different content should produce different hash
|
|
||||||
err = os.WriteFile(testFile, []byte("different content"), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
hash3, err := planner.calculateManifestHash(testFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotEqual(t, hash1, hash3)
|
|
||||||
|
|
||||||
// Empty file path should return empty hash
|
|
||||||
hash4, err := planner.calculateManifestHash("")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, hash4)
|
|
||||||
|
|
||||||
// Non-existent file should return error
|
|
||||||
_, err = planner.calculateManifestHash("/non/existent/file")
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareAppStates(t *testing.T) {
|
|
||||||
planner := &EdgeConnectPlanner{}
|
|
||||||
|
|
||||||
current := &AppState{
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
AppType: AppTypeK8s,
|
|
||||||
ManifestHash: "old-hash",
|
|
||||||
}
|
|
||||||
|
|
||||||
desired := &AppState{
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
AppType: AppTypeK8s,
|
|
||||||
ManifestHash: "new-hash",
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, manifestChanged := planner.compareAppStates(current, desired)
|
|
||||||
assert.Len(t, changes, 1)
|
|
||||||
assert.True(t, manifestChanged)
|
|
||||||
assert.Contains(t, changes[0], "Manifest hash changed")
|
|
||||||
|
|
||||||
// Test no changes
|
|
||||||
desired.ManifestHash = "old-hash"
|
|
||||||
changes, manifestChanged = planner.compareAppStates(current, desired)
|
|
||||||
assert.Empty(t, changes)
|
|
||||||
assert.False(t, manifestChanged)
|
|
||||||
|
|
||||||
// Test app type change
|
|
||||||
desired.AppType = AppTypeDocker
|
|
||||||
changes, manifestChanged = planner.compareAppStates(current, desired)
|
|
||||||
assert.Len(t, changes, 1)
|
|
||||||
assert.False(t, manifestChanged)
|
|
||||||
assert.Contains(t, changes[0], "App type changed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareAppStatesOutboundConnections(t *testing.T) {
|
|
||||||
planner := &EdgeConnectPlanner{}
|
|
||||||
|
|
||||||
// Test with no outbound connections
|
|
||||||
current := &AppState{
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
AppType: AppTypeK8s,
|
|
||||||
OutboundConnections: nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
desired := &AppState{
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
AppType: AppTypeK8s,
|
|
||||||
OutboundConnections: nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, _ := planner.compareAppStates(current, desired)
|
|
||||||
assert.Empty(t, changes, "No changes expected when both have no outbound connections")
|
|
||||||
|
|
||||||
// Test adding outbound connections
|
|
||||||
desired.OutboundConnections = []SecurityRule{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, _ = planner.compareAppStates(current, desired)
|
|
||||||
assert.Len(t, changes, 1)
|
|
||||||
assert.Contains(t, changes[0], "Outbound connections changed")
|
|
||||||
|
|
||||||
// Test identical outbound connections
|
|
||||||
current.OutboundConnections = []SecurityRule{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, _ = planner.compareAppStates(current, desired)
|
|
||||||
assert.Empty(t, changes, "No changes expected when outbound connections are identical")
|
|
||||||
|
|
||||||
// Test different outbound connections (different port)
|
|
||||||
desired.OutboundConnections[0].PortRangeMin = 443
|
|
||||||
desired.OutboundConnections[0].PortRangeMax = 443
|
|
||||||
|
|
||||||
changes, _ = planner.compareAppStates(current, desired)
|
|
||||||
assert.Len(t, changes, 1)
|
|
||||||
assert.Contains(t, changes[0], "Outbound connections changed")
|
|
||||||
|
|
||||||
// Test same connections but different order (should be considered equal)
|
|
||||||
current.OutboundConnections = []SecurityRule{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 443,
|
|
||||||
PortRangeMax: 443,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
desired.OutboundConnections = []SecurityRule{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 443,
|
|
||||||
PortRangeMax: 443,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, _ = planner.compareAppStates(current, desired)
|
|
||||||
assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order")
|
|
||||||
|
|
||||||
// Test removing outbound connections
|
|
||||||
desired.OutboundConnections = nil
|
|
||||||
|
|
||||||
changes, _ = planner.compareAppStates(current, desired)
|
|
||||||
assert.Len(t, changes, 1)
|
|
||||||
assert.Contains(t, changes[0], "Outbound connections changed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareInstanceStates(t *testing.T) {
|
|
||||||
planner := &EdgeConnectPlanner{}
|
|
||||||
|
|
||||||
current := &InstanceState{
|
|
||||||
Name: "test-instance",
|
|
||||||
FlavorName: "small",
|
|
||||||
CloudletName: "oldcloudlet",
|
|
||||||
CloudletOrg: "oldorg",
|
|
||||||
}
|
|
||||||
|
|
||||||
desired := &InstanceState{
|
|
||||||
Name: "test-instance",
|
|
||||||
FlavorName: "medium",
|
|
||||||
CloudletName: "newcloudlet",
|
|
||||||
CloudletOrg: "neworg",
|
|
||||||
}
|
|
||||||
|
|
||||||
changes := planner.compareInstanceStates(current, desired)
|
|
||||||
assert.Len(t, changes, 3)
|
|
||||||
assert.Contains(t, changes[0], "Flavor changed")
|
|
||||||
assert.Contains(t, changes[1], "Cloudlet changed")
|
|
||||||
assert.Contains(t, changes[2], "Cloudlet org changed")
|
|
||||||
|
|
||||||
// Test no changes
|
|
||||||
desired.FlavorName = "small"
|
|
||||||
desired.CloudletName = "oldcloudlet"
|
|
||||||
desired.CloudletOrg = "oldorg"
|
|
||||||
changes = planner.compareInstanceStates(current, desired)
|
|
||||||
assert.Empty(t, changes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeploymentPlanMethods(t *testing.T) {
|
|
||||||
plan := &DeploymentPlan{
|
|
||||||
ConfigName: "test-plan",
|
|
||||||
AppAction: AppAction{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Desired: &AppState{Name: "test-app"},
|
|
||||||
},
|
|
||||||
InstanceActions: []InstanceAction{
|
|
||||||
{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Target: config.InfraTemplate{
|
|
||||||
CloudletOrg: "org1",
|
|
||||||
CloudletName: "cloudlet1",
|
|
||||||
Region: "US",
|
|
||||||
},
|
|
||||||
InstanceName: "instance1",
|
|
||||||
Desired: &InstanceState{Name: "instance1"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: ActionUpdate,
|
|
||||||
Target: config.InfraTemplate{
|
|
||||||
CloudletOrg: "org2",
|
|
||||||
CloudletName: "cloudlet2",
|
|
||||||
Region: "EU",
|
|
||||||
},
|
|
||||||
InstanceName: "instance2",
|
|
||||||
Desired: &InstanceState{Name: "instance2"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test IsEmpty
|
|
||||||
assert.False(t, plan.IsEmpty())
|
|
||||||
|
|
||||||
// Test GetTargetCloudlets
|
|
||||||
cloudlets := plan.GetTargetCloudlets()
|
|
||||||
assert.Len(t, cloudlets, 2)
|
|
||||||
assert.Contains(t, cloudlets, "org1:cloudlet1")
|
|
||||||
assert.Contains(t, cloudlets, "org2:cloudlet2")
|
|
||||||
|
|
||||||
// Test GetTargetRegions
|
|
||||||
regions := plan.GetTargetRegions()
|
|
||||||
assert.Len(t, regions, 2)
|
|
||||||
assert.Contains(t, regions, "US")
|
|
||||||
assert.Contains(t, regions, "EU")
|
|
||||||
|
|
||||||
// Test GenerateSummary
|
|
||||||
summary := plan.GenerateSummary()
|
|
||||||
assert.Contains(t, summary, "test-plan")
|
|
||||||
assert.Contains(t, summary, "CREATE application")
|
|
||||||
assert.Contains(t, summary, "CREATE 1 instance")
|
|
||||||
assert.Contains(t, summary, "UPDATE 1 instance")
|
|
||||||
|
|
||||||
// Test Validate
|
|
||||||
err := plan.Validate()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test validation failure
|
|
||||||
plan.AppAction.Desired = nil
|
|
||||||
err = plan.Validate()
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "must have desired state")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEstimateDeploymentDuration(t *testing.T) {
|
|
||||||
planner := &EdgeConnectPlanner{}
|
|
||||||
|
|
||||||
plan := &DeploymentPlan{
|
|
||||||
AppAction: AppAction{Type: ActionCreate},
|
|
||||||
InstanceActions: []InstanceAction{
|
|
||||||
{Type: ActionCreate},
|
|
||||||
{Type: ActionUpdate},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
duration := planner.estimateDeploymentDuration(plan)
|
|
||||||
assert.Greater(t, duration, time.Duration(0))
|
|
||||||
assert.Less(t, duration, 10*time.Minute) // Reasonable upper bound
|
|
||||||
|
|
||||||
// Test with no actions
|
|
||||||
emptyPlan := &DeploymentPlan{
|
|
||||||
AppAction: AppAction{Type: ActionNone},
|
|
||||||
InstanceActions: []InstanceAction{},
|
|
||||||
}
|
|
||||||
|
|
||||||
emptyDuration := planner.estimateDeploymentDuration(emptyPlan)
|
|
||||||
assert.Greater(t, emptyDuration, time.Duration(0))
|
|
||||||
assert.Less(t, emptyDuration, duration) // Should be less than plan with actions
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsResourceNotFoundError(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"nil error", nil, false},
|
|
||||||
{"not found error", &edgeconnect.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true},
|
|
||||||
{"does not exist error", &edgeconnect.APIError{Messages: []string{"App does not exist"}}, true},
|
|
||||||
{"404 in message", &edgeconnect.APIError{Messages: []string{"HTTP 404 error"}}, true},
|
|
||||||
{"other error", &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := isResourceNotFoundError(tt.err)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanErrorHandling(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
// Mock API call to return a non-404 error
|
|
||||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("edgeconnect.AppKey"), "US").
|
|
||||||
Return(nil, &edgeconnect.APIError{StatusCode: 500, Messages: []string{"Server error"}})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.Plan(ctx, testConfig)
|
|
||||||
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.NotNil(t, result)
|
|
||||||
assert.NotNil(t, result.Error)
|
|
||||||
assert.Contains(t, err.Error(), "failed to query current app state")
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
|
|
||||||
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
|
|
||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DeploymentStrategy represents the type of deployment strategy
|
|
||||||
type DeploymentStrategy string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// StrategyRecreate deletes all instances, updates app, then creates new instances
|
|
||||||
StrategyRecreate DeploymentStrategy = "recreate"
|
|
||||||
|
|
||||||
// StrategyBlueGreen creates new instances alongside old ones, then switches traffic (future)
|
|
||||||
StrategyBlueGreen DeploymentStrategy = "blue-green"
|
|
||||||
|
|
||||||
// StrategyRolling updates instances one by one with health checks (future)
|
|
||||||
StrategyRolling DeploymentStrategy = "rolling"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DeploymentStrategyExecutor defines the interface that all deployment strategies must implement
|
|
||||||
type DeploymentStrategyExecutor interface {
|
|
||||||
// Execute runs the deployment strategy
|
|
||||||
Execute(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error)
|
|
||||||
|
|
||||||
// Validate checks if the strategy can be used for this deployment
|
|
||||||
Validate(plan *DeploymentPlan) error
|
|
||||||
|
|
||||||
// EstimateDuration provides time estimate for this strategy
|
|
||||||
EstimateDuration(plan *DeploymentPlan) time.Duration
|
|
||||||
|
|
||||||
// GetName returns the strategy name
|
|
||||||
GetName() DeploymentStrategy
|
|
||||||
}
|
|
||||||
|
|
||||||
// StrategyConfig holds configuration for deployment strategies
|
|
||||||
type StrategyConfig struct {
|
|
||||||
// MaxRetries is the number of times to retry failed operations
|
|
||||||
MaxRetries int
|
|
||||||
|
|
||||||
// HealthCheckTimeout is the maximum time to wait for health checks
|
|
||||||
HealthCheckTimeout time.Duration
|
|
||||||
|
|
||||||
// ParallelOperations enables parallel execution of operations
|
|
||||||
ParallelOperations bool
|
|
||||||
|
|
||||||
// RetryDelay is the delay between retry attempts
|
|
||||||
RetryDelay time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultStrategyConfig returns sensible defaults for strategy configuration
|
|
||||||
func DefaultStrategyConfig() StrategyConfig {
|
|
||||||
return StrategyConfig{
|
|
||||||
MaxRetries: 5, // Retry 5 times
|
|
||||||
HealthCheckTimeout: 5 * time.Minute, // Max 5 mins health check
|
|
||||||
ParallelOperations: true, // Parallel execution
|
|
||||||
RetryDelay: 10 * time.Second, // 10s between retries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StrategyFactory creates deployment strategy executors
|
|
||||||
type StrategyFactory struct {
|
|
||||||
config StrategyConfig
|
|
||||||
client EdgeConnectClientInterface
|
|
||||||
logger Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStrategyFactory creates a new strategy factory
|
|
||||||
func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *StrategyFactory {
|
|
||||||
return &StrategyFactory{
|
|
||||||
config: config,
|
|
||||||
client: client,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateStrategy creates the appropriate strategy executor based on the deployment strategy
|
|
||||||
func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) {
|
|
||||||
switch strategy {
|
|
||||||
case StrategyRecreate:
|
|
||||||
return NewRecreateStrategy(f.client, f.config, f.logger), nil
|
|
||||||
case StrategyBlueGreen:
|
|
||||||
// TODO: Implement blue-green strategy
|
|
||||||
return nil, fmt.Errorf("blue-green strategy not yet implemented")
|
|
||||||
case StrategyRolling:
|
|
||||||
// TODO: Implement rolling strategy
|
|
||||||
return nil, fmt.Errorf("rolling strategy not yet implemented")
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unknown deployment strategy: %s", strategy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAvailableStrategies returns a list of all available strategies
|
|
||||||
func (f *StrategyFactory) GetAvailableStrategies() []DeploymentStrategy {
|
|
||||||
return []DeploymentStrategy{
|
|
||||||
StrategyRecreate,
|
|
||||||
// StrategyBlueGreen, // TODO: Enable when implemented
|
|
||||||
// StrategyRolling, // TODO: Enable when implemented
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,462 +0,0 @@
|
||||||
// ABOUTME: Deployment planning types for EdgeConnect apply command with state management
|
|
||||||
// ABOUTME: Defines structures for deployment plans, actions, and state comparison results
|
|
||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SecurityRule defines network access rules (alias to SDK type for consistency)
|
|
||||||
type SecurityRule = edgeconnect.SecurityRule
|
|
||||||
|
|
||||||
// ActionType represents the type of action to be performed
|
|
||||||
type ActionType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ActionCreate indicates a resource needs to be created
|
|
||||||
ActionCreate ActionType = "CREATE"
|
|
||||||
// ActionUpdate indicates a resource needs to be updated
|
|
||||||
ActionUpdate ActionType = "UPDATE"
|
|
||||||
// ActionNone indicates no action is needed
|
|
||||||
ActionNone ActionType = "NONE"
|
|
||||||
// ActionDelete indicates a resource needs to be deleted (for rollback scenarios)
|
|
||||||
ActionDelete ActionType = "DELETE"
|
|
||||||
)
|
|
||||||
|
|
||||||
// String returns the string representation of ActionType
|
|
||||||
func (a ActionType) String() string {
|
|
||||||
return string(a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeploymentPlan represents the complete deployment plan for a configuration
|
|
||||||
type DeploymentPlan struct {
|
|
||||||
// ConfigName is the name from metadata
|
|
||||||
ConfigName string
|
|
||||||
|
|
||||||
// AppAction defines what needs to be done with the application
|
|
||||||
AppAction AppAction
|
|
||||||
|
|
||||||
// InstanceActions defines what needs to be done with each instance
|
|
||||||
InstanceActions []InstanceAction
|
|
||||||
|
|
||||||
// Summary provides a human-readable summary of the plan
|
|
||||||
Summary string
|
|
||||||
|
|
||||||
// TotalActions is the count of all actions that will be performed
|
|
||||||
TotalActions int
|
|
||||||
|
|
||||||
// EstimatedDuration is the estimated time to complete the deployment
|
|
||||||
EstimatedDuration time.Duration
|
|
||||||
|
|
||||||
// CreatedAt timestamp when the plan was created
|
|
||||||
CreatedAt time.Time
|
|
||||||
|
|
||||||
// DryRun indicates if this is a dry-run plan
|
|
||||||
DryRun bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppAction represents an action to be performed on an application
|
|
||||||
type AppAction struct {
|
|
||||||
// Type of action to perform
|
|
||||||
Type ActionType
|
|
||||||
|
|
||||||
// Current state of the app (nil if doesn't exist)
|
|
||||||
Current *AppState
|
|
||||||
|
|
||||||
// Desired state of the app
|
|
||||||
Desired *AppState
|
|
||||||
|
|
||||||
// Changes describes what will change
|
|
||||||
Changes []string
|
|
||||||
|
|
||||||
// Reason explains why this action is needed
|
|
||||||
Reason string
|
|
||||||
|
|
||||||
// ManifestHash is the hash of the current manifest file
|
|
||||||
ManifestHash string
|
|
||||||
|
|
||||||
// ManifestChanged indicates if the manifest content has changed
|
|
||||||
ManifestChanged bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstanceAction represents an action to be performed on an application instance
|
|
||||||
type InstanceAction struct {
|
|
||||||
// Type of action to perform
|
|
||||||
Type ActionType
|
|
||||||
|
|
||||||
// Target infrastructure where the instance will be deployed
|
|
||||||
Target config.InfraTemplate
|
|
||||||
|
|
||||||
// Current state of the instance (nil if doesn't exist)
|
|
||||||
Current *InstanceState
|
|
||||||
|
|
||||||
// Desired state of the instance
|
|
||||||
Desired *InstanceState
|
|
||||||
|
|
||||||
// Changes describes what will change
|
|
||||||
Changes []string
|
|
||||||
|
|
||||||
// Reason explains why this action is needed
|
|
||||||
Reason string
|
|
||||||
|
|
||||||
// InstanceName is the generated name for this instance
|
|
||||||
InstanceName string
|
|
||||||
|
|
||||||
// Dependencies lists other instances this depends on
|
|
||||||
Dependencies []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppState represents the current state of an application
|
|
||||||
type AppState struct {
|
|
||||||
// Name of the application
|
|
||||||
Name string
|
|
||||||
|
|
||||||
// Version of the application
|
|
||||||
Version string
|
|
||||||
|
|
||||||
// Organization that owns the app
|
|
||||||
Organization string
|
|
||||||
|
|
||||||
// Region where the app is deployed
|
|
||||||
Region string
|
|
||||||
|
|
||||||
// ManifestHash is the stored hash of the manifest file
|
|
||||||
ManifestHash string
|
|
||||||
|
|
||||||
// LastUpdated timestamp when the app was last modified
|
|
||||||
LastUpdated time.Time
|
|
||||||
|
|
||||||
// Exists indicates if the app currently exists
|
|
||||||
Exists bool
|
|
||||||
|
|
||||||
// AppType indicates whether this is a k8s or docker app
|
|
||||||
AppType AppType
|
|
||||||
|
|
||||||
// OutboundConnections contains the required outbound network connections
|
|
||||||
OutboundConnections []SecurityRule
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstanceState represents the current state of an application instance
|
|
||||||
type InstanceState struct {
|
|
||||||
// Name of the instance
|
|
||||||
Name string
|
|
||||||
|
|
||||||
// AppName that this instance belongs to
|
|
||||||
AppName string
|
|
||||||
|
|
||||||
// AppVersion of the associated app
|
|
||||||
AppVersion string
|
|
||||||
|
|
||||||
// Organization that owns the instance
|
|
||||||
Organization string
|
|
||||||
|
|
||||||
// Region where the instance is deployed
|
|
||||||
Region string
|
|
||||||
|
|
||||||
// CloudletOrg that hosts the cloudlet
|
|
||||||
CloudletOrg string
|
|
||||||
|
|
||||||
// CloudletName where the instance is running
|
|
||||||
CloudletName string
|
|
||||||
|
|
||||||
// FlavorName used for the instance
|
|
||||||
FlavorName string
|
|
||||||
|
|
||||||
// State of the instance (e.g., "Ready", "Pending", "Error")
|
|
||||||
State string
|
|
||||||
|
|
||||||
// PowerState of the instance
|
|
||||||
PowerState string
|
|
||||||
|
|
||||||
// LastUpdated timestamp when the instance was last modified
|
|
||||||
LastUpdated time.Time
|
|
||||||
|
|
||||||
// Exists indicates if the instance currently exists
|
|
||||||
Exists bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppType represents the type of application
|
|
||||||
type AppType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// AppTypeK8s represents a Kubernetes application
|
|
||||||
AppTypeK8s AppType = "k8s"
|
|
||||||
// AppTypeDocker represents a Docker application
|
|
||||||
AppTypeDocker AppType = "docker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// String returns the string representation of AppType
|
|
||||||
func (a AppType) String() string {
|
|
||||||
return string(a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeploymentSummary provides a high-level overview of the deployment plan
|
|
||||||
type DeploymentSummary struct {
|
|
||||||
// TotalActions is the total number of actions to be performed
|
|
||||||
TotalActions int
|
|
||||||
|
|
||||||
// ActionCounts breaks down actions by type
|
|
||||||
ActionCounts map[ActionType]int
|
|
||||||
|
|
||||||
// EstimatedDuration for the entire deployment
|
|
||||||
EstimatedDuration time.Duration
|
|
||||||
|
|
||||||
// ResourceSummary describes the resources involved
|
|
||||||
ResourceSummary ResourceSummary
|
|
||||||
|
|
||||||
// Warnings about potential issues
|
|
||||||
Warnings []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResourceSummary provides details about resources in the deployment
|
|
||||||
type ResourceSummary struct {
|
|
||||||
// AppsToCreate number of apps that will be created
|
|
||||||
AppsToCreate int
|
|
||||||
|
|
||||||
// AppsToUpdate number of apps that will be updated
|
|
||||||
AppsToUpdate int
|
|
||||||
|
|
||||||
// InstancesToCreate number of instances that will be created
|
|
||||||
InstancesToCreate int
|
|
||||||
|
|
||||||
// InstancesToUpdate number of instances that will be updated
|
|
||||||
InstancesToUpdate int
|
|
||||||
|
|
||||||
// CloudletsAffected number of unique cloudlets involved
|
|
||||||
CloudletsAffected int
|
|
||||||
|
|
||||||
// RegionsAffected number of unique regions involved
|
|
||||||
RegionsAffected int
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanResult represents the result of a deployment planning operation
|
|
||||||
type PlanResult struct {
|
|
||||||
// Plan is the generated deployment plan
|
|
||||||
Plan *DeploymentPlan
|
|
||||||
|
|
||||||
// Error if planning failed
|
|
||||||
Error error
|
|
||||||
|
|
||||||
// Warnings encountered during planning
|
|
||||||
Warnings []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecutionResult represents the result of executing a deployment plan
|
|
||||||
type ExecutionResult struct {
|
|
||||||
// Plan that was executed
|
|
||||||
Plan *DeploymentPlan
|
|
||||||
|
|
||||||
// Success indicates if the deployment was successful
|
|
||||||
Success bool
|
|
||||||
|
|
||||||
// CompletedActions lists actions that were successfully completed
|
|
||||||
CompletedActions []ActionResult
|
|
||||||
|
|
||||||
// FailedActions lists actions that failed
|
|
||||||
FailedActions []ActionResult
|
|
||||||
|
|
||||||
// Error that caused the deployment to fail (if any)
|
|
||||||
Error error
|
|
||||||
|
|
||||||
// Duration taken to execute the plan
|
|
||||||
Duration time.Duration
|
|
||||||
|
|
||||||
// RollbackPerformed indicates if rollback was executed
|
|
||||||
RollbackPerformed bool
|
|
||||||
|
|
||||||
// RollbackSuccess indicates if rollback was successful
|
|
||||||
RollbackSuccess bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ActionResult represents the result of executing a single action
|
|
||||||
type ActionResult struct {
|
|
||||||
// Type of action that was attempted
|
|
||||||
Type ActionType
|
|
||||||
|
|
||||||
// Target describes what was being acted upon
|
|
||||||
Target string
|
|
||||||
|
|
||||||
// Success indicates if the action succeeded
|
|
||||||
Success bool
|
|
||||||
|
|
||||||
// Error if the action failed
|
|
||||||
Error error
|
|
||||||
|
|
||||||
// Duration taken to complete the action
|
|
||||||
Duration time.Duration
|
|
||||||
|
|
||||||
// Details provides additional information about the action
|
|
||||||
Details string
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty returns true if the deployment plan has no actions to perform
|
|
||||||
func (dp *DeploymentPlan) IsEmpty() bool {
|
|
||||||
if dp.AppAction.Type != ActionNone {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, action := range dp.InstanceActions {
|
|
||||||
if action.Type != ActionNone {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasErrors returns true if the plan contains any error conditions
|
|
||||||
func (dp *DeploymentPlan) HasErrors() bool {
|
|
||||||
// Check for conflicting actions or invalid states
|
|
||||||
return false // Implementation would check for various error conditions
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTargetCloudlets returns a list of unique cloudlets that will be affected
|
|
||||||
func (dp *DeploymentPlan) GetTargetCloudlets() []string {
|
|
||||||
cloudletSet := make(map[string]bool)
|
|
||||||
var cloudlets []string
|
|
||||||
|
|
||||||
for _, action := range dp.InstanceActions {
|
|
||||||
if action.Type != ActionNone {
|
|
||||||
key := fmt.Sprintf("%s:%s", action.Target.CloudletOrg, action.Target.CloudletName)
|
|
||||||
if !cloudletSet[key] {
|
|
||||||
cloudletSet[key] = true
|
|
||||||
cloudlets = append(cloudlets, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cloudlets
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTargetRegions returns a list of unique regions that will be affected
|
|
||||||
func (dp *DeploymentPlan) GetTargetRegions() []string {
|
|
||||||
regionSet := make(map[string]bool)
|
|
||||||
var regions []string
|
|
||||||
|
|
||||||
for _, action := range dp.InstanceActions {
|
|
||||||
if action.Type != ActionNone && !regionSet[action.Target.Region] {
|
|
||||||
regionSet[action.Target.Region] = true
|
|
||||||
regions = append(regions, action.Target.Region)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return regions
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateSummary creates a human-readable summary of the deployment plan
|
|
||||||
func (dp *DeploymentPlan) GenerateSummary() string {
|
|
||||||
if dp.IsEmpty() {
|
|
||||||
return "No changes required - configuration matches current state"
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName))
|
|
||||||
|
|
||||||
// App actions
|
|
||||||
if dp.AppAction.Type != ActionNone {
|
|
||||||
sb.WriteString(fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name))
|
|
||||||
if len(dp.AppAction.Changes) > 0 {
|
|
||||||
for _, change := range dp.AppAction.Changes {
|
|
||||||
sb.WriteString(fmt.Sprintf(" - %s\n", change))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instance actions
|
|
||||||
createCount := 0
|
|
||||||
updateActions := []InstanceAction{}
|
|
||||||
for _, action := range dp.InstanceActions {
|
|
||||||
switch action.Type {
|
|
||||||
case ActionCreate:
|
|
||||||
createCount++
|
|
||||||
case ActionUpdate:
|
|
||||||
updateActions = append(updateActions, action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if createCount > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets())))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(updateActions) > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf("- UPDATE %d instance(s)\n", len(updateActions)))
|
|
||||||
for _, action := range updateActions {
|
|
||||||
if len(action.Changes) > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf(" - Instance '%s':\n", action.InstanceName))
|
|
||||||
for _, change := range action.Changes {
|
|
||||||
sb.WriteString(fmt.Sprintf(" - %s\n", change))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate checks if the deployment plan is valid and safe to execute
|
|
||||||
func (dp *DeploymentPlan) Validate() error {
|
|
||||||
if dp.ConfigName == "" {
|
|
||||||
return fmt.Errorf("deployment plan must have a config name")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate app action
|
|
||||||
if dp.AppAction.Type != ActionNone && dp.AppAction.Desired == nil {
|
|
||||||
return fmt.Errorf("app action of type %s must have desired state", dp.AppAction.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate instance actions
|
|
||||||
for i, action := range dp.InstanceActions {
|
|
||||||
if action.Type != ActionNone {
|
|
||||||
if action.Desired == nil {
|
|
||||||
return fmt.Errorf("instance action %d of type %s must have desired state", i, action.Type)
|
|
||||||
}
|
|
||||||
if action.InstanceName == "" {
|
|
||||||
return fmt.Errorf("instance action %d must have an instance name", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone creates a deep copy of the deployment plan
|
|
||||||
func (dp *DeploymentPlan) Clone() *DeploymentPlan {
|
|
||||||
clone := &DeploymentPlan{
|
|
||||||
ConfigName: dp.ConfigName,
|
|
||||||
Summary: dp.Summary,
|
|
||||||
TotalActions: dp.TotalActions,
|
|
||||||
EstimatedDuration: dp.EstimatedDuration,
|
|
||||||
CreatedAt: dp.CreatedAt,
|
|
||||||
DryRun: dp.DryRun,
|
|
||||||
AppAction: dp.AppAction, // Struct copy is sufficient for this use case
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep copy instance actions
|
|
||||||
clone.InstanceActions = make([]InstanceAction, len(dp.InstanceActions))
|
|
||||||
copy(clone.InstanceActions, dp.InstanceActions)
|
|
||||||
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
// convertNetworkRules converts config network rules to EdgeConnect SecurityRules
|
|
||||||
func convertNetworkRules(network *config.NetworkConfig) []edgeconnect.SecurityRule {
|
|
||||||
rules := make([]edgeconnect.SecurityRule, len(network.OutboundConnections))
|
|
||||||
|
|
||||||
for i, conn := range network.OutboundConnections {
|
|
||||||
rules[i] = edgeconnect.SecurityRule{
|
|
||||||
Protocol: conn.Protocol,
|
|
||||||
PortRangeMin: conn.PortRangeMin,
|
|
||||||
PortRangeMax: conn.PortRangeMax,
|
|
||||||
RemoteCIDR: conn.RemoteCIDR,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rules
|
|
||||||
}
|
|
||||||
|
|
@ -1,434 +0,0 @@
|
||||||
// ABOUTME: Resource management for EdgeConnect apply command with deployment execution and rollback
|
|
||||||
// ABOUTME: Handles actual deployment operations, manifest processing, and error recovery with parallel execution
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ResourceManagerInterface defines the interface for resource management
|
|
||||||
type ResourceManagerInterface interface {
|
|
||||||
// ApplyDeployment executes a deployment plan
|
|
||||||
ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error)
|
|
||||||
|
|
||||||
// RollbackDeployment attempts to rollback a failed deployment
|
|
||||||
RollbackDeployment(ctx context.Context, result *ExecutionResult) error
|
|
||||||
|
|
||||||
// ValidatePrerequisites checks if deployment prerequisites are met
|
|
||||||
ValidatePrerequisites(ctx context.Context, plan *DeploymentPlan) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// EdgeConnectResourceManager implements resource management for EdgeConnect
|
|
||||||
type EdgeConnectResourceManager struct {
|
|
||||||
client EdgeConnectClientInterface
|
|
||||||
parallelLimit int
|
|
||||||
rollbackOnFail bool
|
|
||||||
logger Logger
|
|
||||||
strategyConfig StrategyConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logger interface for deployment logging
|
|
||||||
type Logger interface {
|
|
||||||
Printf(format string, v ...interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResourceManagerOptions configures the resource manager behavior
|
|
||||||
type ResourceManagerOptions struct {
|
|
||||||
// ParallelLimit controls how many operations run concurrently
|
|
||||||
ParallelLimit int
|
|
||||||
|
|
||||||
// RollbackOnFail automatically rolls back on deployment failure
|
|
||||||
RollbackOnFail bool
|
|
||||||
|
|
||||||
// Logger for deployment operations
|
|
||||||
Logger Logger
|
|
||||||
|
|
||||||
// Timeout for individual operations
|
|
||||||
OperationTimeout time.Duration
|
|
||||||
|
|
||||||
// StrategyConfig for deployment strategies
|
|
||||||
StrategyConfig StrategyConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultResourceManagerOptions returns sensible defaults
|
|
||||||
func DefaultResourceManagerOptions() ResourceManagerOptions {
|
|
||||||
return ResourceManagerOptions{
|
|
||||||
ParallelLimit: 5, // Conservative parallel limit
|
|
||||||
RollbackOnFail: true,
|
|
||||||
OperationTimeout: 2 * time.Minute,
|
|
||||||
StrategyConfig: DefaultStrategyConfig(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewResourceManager creates a new EdgeConnect resource manager
|
|
||||||
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
|
||||||
options := DefaultResourceManagerOptions()
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&options)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &EdgeConnectResourceManager{
|
|
||||||
client: client,
|
|
||||||
parallelLimit: options.ParallelLimit,
|
|
||||||
rollbackOnFail: options.RollbackOnFail,
|
|
||||||
logger: options.Logger,
|
|
||||||
strategyConfig: options.StrategyConfig,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithParallelLimit sets the parallel execution limit
|
|
||||||
func WithParallelLimit(limit int) func(*ResourceManagerOptions) {
|
|
||||||
return func(opts *ResourceManagerOptions) {
|
|
||||||
opts.ParallelLimit = limit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithRollbackOnFail enables/disables automatic rollback
|
|
||||||
func WithRollbackOnFail(rollback bool) func(*ResourceManagerOptions) {
|
|
||||||
return func(opts *ResourceManagerOptions) {
|
|
||||||
opts.RollbackOnFail = rollback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithLogger sets a logger for deployment operations
|
|
||||||
func WithLogger(logger Logger) func(*ResourceManagerOptions) {
|
|
||||||
return func(opts *ResourceManagerOptions) {
|
|
||||||
opts.Logger = logger
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithStrategyConfig sets the strategy configuration
|
|
||||||
func WithStrategyConfig(config StrategyConfig) func(*ResourceManagerOptions) {
|
|
||||||
return func(opts *ResourceManagerOptions) {
|
|
||||||
opts.StrategyConfig = config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyDeployment executes a deployment plan using deployment strategies
|
|
||||||
func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) {
|
|
||||||
rm.logf("Starting deployment: %s", plan.ConfigName)
|
|
||||||
|
|
||||||
// Step 1: Validate prerequisites
|
|
||||||
if err := rm.ValidatePrerequisites(ctx, plan); err != nil {
|
|
||||||
result := &ExecutionResult{
|
|
||||||
Plan: plan,
|
|
||||||
CompletedActions: []ActionResult{},
|
|
||||||
FailedActions: []ActionResult{},
|
|
||||||
Error: fmt.Errorf("prerequisites validation failed: %w", err),
|
|
||||||
Duration: 0,
|
|
||||||
}
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Determine deployment strategy
|
|
||||||
strategyName := DeploymentStrategy(config.Spec.GetDeploymentStrategy())
|
|
||||||
rm.logf("Using deployment strategy: %s", strategyName)
|
|
||||||
|
|
||||||
// Step 3: Create strategy executor
|
|
||||||
strategyConfig := rm.strategyConfig
|
|
||||||
strategyConfig.ParallelOperations = rm.parallelLimit > 1
|
|
||||||
|
|
||||||
factory := NewStrategyFactory(rm.client, strategyConfig, rm.logger)
|
|
||||||
strategy, err := factory.CreateStrategy(strategyName)
|
|
||||||
if err != nil {
|
|
||||||
result := &ExecutionResult{
|
|
||||||
Plan: plan,
|
|
||||||
CompletedActions: []ActionResult{},
|
|
||||||
FailedActions: []ActionResult{},
|
|
||||||
Error: fmt.Errorf("failed to create deployment strategy: %w", err),
|
|
||||||
Duration: 0,
|
|
||||||
}
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Validate strategy can handle this deployment
|
|
||||||
if err := strategy.Validate(plan); err != nil {
|
|
||||||
result := &ExecutionResult{
|
|
||||||
Plan: plan,
|
|
||||||
CompletedActions: []ActionResult{},
|
|
||||||
FailedActions: []ActionResult{},
|
|
||||||
Error: fmt.Errorf("strategy validation failed: %w", err),
|
|
||||||
Duration: 0,
|
|
||||||
}
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Execute the deployment strategy
|
|
||||||
rm.logf("Estimated deployment duration: %v", strategy.EstimateDuration(plan))
|
|
||||||
result, err := strategy.Execute(ctx, plan, config, manifestContent)
|
|
||||||
|
|
||||||
// Step 6: Handle rollback if needed
|
|
||||||
if err != nil && rm.rollbackOnFail && result != nil {
|
|
||||||
rm.logf("Deployment failed, attempting rollback...")
|
|
||||||
if rollbackErr := rm.RollbackDeployment(ctx, result); rollbackErr != nil {
|
|
||||||
rm.logf("Rollback failed: %v", rollbackErr)
|
|
||||||
} else {
|
|
||||||
result.RollbackPerformed = true
|
|
||||||
result.RollbackSuccess = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != nil && result.Success {
|
|
||||||
rm.logf("Deployment completed successfully in %v", result.Duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidatePrerequisites checks if deployment prerequisites are met
|
|
||||||
func (rm *EdgeConnectResourceManager) ValidatePrerequisites(ctx context.Context, plan *DeploymentPlan) error {
|
|
||||||
rm.logf("Validating deployment prerequisites for: %s", plan.ConfigName)
|
|
||||||
|
|
||||||
// Check if we have any actions to perform
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
return fmt.Errorf("deployment plan is empty - no actions to perform")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that we have required client capabilities
|
|
||||||
if rm.client == nil {
|
|
||||||
return fmt.Errorf("EdgeConnect client is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
rm.logf("Prerequisites validation passed")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RollbackDeployment attempts to rollback a failed deployment
|
|
||||||
func (rm *EdgeConnectResourceManager) RollbackDeployment(ctx context.Context, result *ExecutionResult) error {
|
|
||||||
rm.logf("Starting rollback for deployment: %s", result.Plan.ConfigName)
|
|
||||||
|
|
||||||
rollbackErrors := []error{}
|
|
||||||
|
|
||||||
// Phase 1: Delete resources that were created in this deployment attempt (in reverse order)
|
|
||||||
rm.logf("Phase 1: Rolling back created resources")
|
|
||||||
for i := len(result.CompletedActions) - 1; i >= 0; i-- {
|
|
||||||
action := result.CompletedActions[i]
|
|
||||||
|
|
||||||
switch action.Type {
|
|
||||||
case ActionCreate:
|
|
||||||
if err := rm.rollbackCreateAction(ctx, action, result.Plan); err != nil {
|
|
||||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to rollback %s: %w", action.Target, err))
|
|
||||||
} else {
|
|
||||||
rm.logf("Successfully rolled back: %s", action.Target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Restore resources that were deleted before the failed deployment
|
|
||||||
// This is critical for RecreateStrategy which deletes everything before recreating
|
|
||||||
if result.DeletedAppBackup != nil || len(result.DeletedInstancesBackup) > 0 {
|
|
||||||
rm.logf("Phase 2: Restoring deleted resources")
|
|
||||||
|
|
||||||
// Restore app first (must exist before instances can be created)
|
|
||||||
if result.DeletedAppBackup != nil {
|
|
||||||
if err := rm.restoreApp(ctx, result.DeletedAppBackup); err != nil {
|
|
||||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to restore app: %w", err))
|
|
||||||
rm.logf("Failed to restore app: %v", err)
|
|
||||||
} else {
|
|
||||||
rm.logf("Successfully restored app: %s", result.DeletedAppBackup.App.Key.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore instances
|
|
||||||
for _, backup := range result.DeletedInstancesBackup {
|
|
||||||
if err := rm.restoreInstance(ctx, &backup); err != nil {
|
|
||||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to restore instance %s: %w", backup.Instance.Key.Name, err))
|
|
||||||
rm.logf("Failed to restore instance %s: %v", backup.Instance.Key.Name, err)
|
|
||||||
} else {
|
|
||||||
rm.logf("Successfully restored instance: %s", backup.Instance.Key.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rollbackErrors) > 0 {
|
|
||||||
return fmt.Errorf("rollback encountered %d errors: %v", len(rollbackErrors), rollbackErrors)
|
|
||||||
}
|
|
||||||
|
|
||||||
rm.logf("Rollback completed successfully")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// rollbackCreateAction rolls back a CREATE action by deleting the resource
|
|
||||||
func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
|
|
||||||
if action.Type != ActionCreate {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if this is an app or instance rollback based on the target name
|
|
||||||
isInstance := false
|
|
||||||
for _, instanceAction := range plan.InstanceActions {
|
|
||||||
if instanceAction.InstanceName == action.Target {
|
|
||||||
isInstance = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isInstance {
|
|
||||||
return rm.rollbackInstance(ctx, action, plan)
|
|
||||||
} else {
|
|
||||||
return rm.rollbackApp(ctx, action, plan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// rollbackApp deletes an application that was created
|
|
||||||
func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
|
|
||||||
appKey := v2.AppKey{
|
|
||||||
Organization: plan.AppAction.Desired.Organization,
|
|
||||||
Name: plan.AppAction.Desired.Name,
|
|
||||||
Version: plan.AppAction.Desired.Version,
|
|
||||||
}
|
|
||||||
|
|
||||||
return rm.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region)
|
|
||||||
}
|
|
||||||
|
|
||||||
// rollbackInstance deletes an instance that was created
|
|
||||||
func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
|
|
||||||
// Find the instance action to get the details
|
|
||||||
for _, instanceAction := range plan.InstanceActions {
|
|
||||||
if instanceAction.InstanceName == action.Target {
|
|
||||||
instanceKey := v2.AppInstanceKey{
|
|
||||||
Organization: plan.AppAction.Desired.Organization,
|
|
||||||
Name: instanceAction.InstanceName,
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: instanceAction.Target.CloudletOrg,
|
|
||||||
Name: instanceAction.Target.CloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return rm.client.DeleteAppInstance(ctx, instanceKey, instanceAction.Target.Region)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("instance action not found for rollback: %s", action.Target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// restoreApp recreates an app that was deleted during deployment
|
|
||||||
func (rm *EdgeConnectResourceManager) restoreApp(ctx context.Context, backup *AppBackup) error {
|
|
||||||
rm.logf("Restoring app: %s/%s version %s",
|
|
||||||
backup.App.Key.Organization, backup.App.Key.Name, backup.App.Key.Version)
|
|
||||||
|
|
||||||
// Build a clean app input with only creation-safe fields
|
|
||||||
// We must exclude read-only fields like CreatedAt, UpdatedAt, etc.
|
|
||||||
appInput := &v2.NewAppInput{
|
|
||||||
Region: backup.Region,
|
|
||||||
App: v2.App{
|
|
||||||
Key: backup.App.Key,
|
|
||||||
Deployment: backup.App.Deployment,
|
|
||||||
ImageType: backup.App.ImageType,
|
|
||||||
ImagePath: backup.App.ImagePath,
|
|
||||||
AllowServerless: backup.App.AllowServerless,
|
|
||||||
DefaultFlavor: backup.App.DefaultFlavor,
|
|
||||||
ServerlessConfig: backup.App.ServerlessConfig,
|
|
||||||
DeploymentManifest: backup.App.DeploymentManifest,
|
|
||||||
DeploymentGenerator: backup.App.DeploymentGenerator,
|
|
||||||
RequiredOutboundConnections: backup.App.RequiredOutboundConnections,
|
|
||||||
// Explicitly omit read-only fields like CreatedAt, UpdatedAt, Fields, etc.
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rm.client.CreateApp(ctx, appInput); err != nil {
|
|
||||||
return fmt.Errorf("failed to restore app: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rm.logf("Successfully restored app: %s", backup.App.Key.Name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// restoreInstance recreates an instance that was deleted during deployment
|
|
||||||
func (rm *EdgeConnectResourceManager) restoreInstance(ctx context.Context, backup *InstanceBackup) error {
|
|
||||||
rm.logf("Restoring instance: %s on %s:%s",
|
|
||||||
backup.Instance.Key.Name,
|
|
||||||
backup.Instance.Key.CloudletKey.Organization,
|
|
||||||
backup.Instance.Key.CloudletKey.Name)
|
|
||||||
|
|
||||||
// Build a clean instance input with only creation-safe fields
|
|
||||||
// We must exclude read-only fields like CloudletLoc, CreatedAt, etc.
|
|
||||||
instanceInput := &v2.NewAppInstanceInput{
|
|
||||||
Region: backup.Region,
|
|
||||||
AppInst: v2.AppInstance{
|
|
||||||
Key: backup.Instance.Key,
|
|
||||||
AppKey: backup.Instance.AppKey,
|
|
||||||
Flavor: backup.Instance.Flavor,
|
|
||||||
// Explicitly omit read-only fields like CloudletLoc, State, PowerState, CreatedAt, etc.
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry logic to handle namespace termination race conditions
|
|
||||||
maxRetries := 5
|
|
||||||
retryDelay := 10 * time.Second
|
|
||||||
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
rm.logf("Retrying instance restore %s (attempt %d/%d)", backup.Instance.Key.Name, attempt, maxRetries)
|
|
||||||
select {
|
|
||||||
case <-time.After(retryDelay):
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := rm.client.CreateAppInstance(ctx, instanceInput)
|
|
||||||
if err == nil {
|
|
||||||
rm.logf("Successfully restored instance: %s", backup.Instance.Key.Name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lastErr = err
|
|
||||||
|
|
||||||
// Check if error is retryable
|
|
||||||
if !rm.isRetryableError(err) {
|
|
||||||
rm.logf("Failed to restore instance %s: %v (non-retryable error, giving up)", backup.Instance.Key.Name, err)
|
|
||||||
return fmt.Errorf("failed to restore instance: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if attempt < maxRetries {
|
|
||||||
rm.logf("Failed to restore instance %s: %v (will retry)", backup.Instance.Key.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to restore instance after %d attempts: %w", maxRetries+1, lastErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isRetryableError determines if an error should be retried
|
|
||||||
func (rm *EdgeConnectResourceManager) isRetryableError(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
errStr := strings.ToLower(err.Error())
|
|
||||||
|
|
||||||
// Special case: Kubernetes namespace termination race condition
|
|
||||||
// This is a transient 400 error that should be retried
|
|
||||||
if strings.Contains(errStr, "being terminated") || strings.Contains(errStr, "is being terminated") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's an APIError with a status code
|
|
||||||
var apiErr *v2.APIError
|
|
||||||
if errors.As(err, &apiErr) {
|
|
||||||
// Don't retry client errors (4xx)
|
|
||||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Retry server errors (5xx)
|
|
||||||
if apiErr.StatusCode >= 500 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry all other errors (network issues, timeouts, etc.)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// logf logs a message if a logger is configured
|
|
||||||
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
|
|
||||||
if rm.logger != nil {
|
|
||||||
rm.logger.Printf("[ResourceManager] "+format, v...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,603 +0,0 @@
|
||||||
// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios
|
|
||||||
// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockResourceClient extends MockEdgeConnectClient with resource management methods
|
|
||||||
type MockResourceClient struct {
|
|
||||||
MockEdgeConnectClient
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockResourceClient) CreateApp(ctx context.Context, input *v2.NewAppInput) error {
|
|
||||||
args := m.Called(ctx, input)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error {
|
|
||||||
args := m.Called(ctx, input)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
|
|
||||||
args := m.Called(ctx, appKey, region)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockResourceClient) UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error {
|
|
||||||
args := m.Called(ctx, input)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error {
|
|
||||||
args := m.Called(ctx, input)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
|
|
||||||
args := m.Called(ctx, instanceKey, region)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLogger implements Logger interface for testing
|
|
||||||
type TestLogger struct {
|
|
||||||
messages []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *TestLogger) Printf(format string, v ...interface{}) {
|
|
||||||
l.messages = append(l.messages, fmt.Sprintf(format, v...))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewResourceManager(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
manager := NewResourceManager(mockClient)
|
|
||||||
|
|
||||||
assert.NotNil(t, manager)
|
|
||||||
assert.IsType(t, &EdgeConnectResourceManager{}, manager)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultResourceManagerOptions(t *testing.T) {
|
|
||||||
opts := DefaultResourceManagerOptions()
|
|
||||||
|
|
||||||
assert.Equal(t, 5, opts.ParallelLimit)
|
|
||||||
assert.True(t, opts.RollbackOnFail)
|
|
||||||
assert.Equal(t, 2*time.Minute, opts.OperationTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithOptions(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
logger := &TestLogger{}
|
|
||||||
|
|
||||||
manager := NewResourceManager(mockClient,
|
|
||||||
WithParallelLimit(10),
|
|
||||||
WithRollbackOnFail(false),
|
|
||||||
WithLogger(logger),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cast to implementation to check options were applied
|
|
||||||
impl := manager.(*EdgeConnectResourceManager)
|
|
||||||
assert.Equal(t, 10, impl.parallelLimit)
|
|
||||||
assert.False(t, impl.rollbackOnFail)
|
|
||||||
assert.Equal(t, logger, impl.logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTestDeploymentPlan() *DeploymentPlan {
|
|
||||||
return &DeploymentPlan{
|
|
||||||
ConfigName: "test-deployment",
|
|
||||||
AppAction: AppAction{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Desired: &AppState{
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
Organization: "testorg",
|
|
||||||
Region: "US",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
InstanceActions: []InstanceAction{
|
|
||||||
{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Target: config.InfraTemplate{
|
|
||||||
Region: "US",
|
|
||||||
CloudletOrg: "cloudletorg",
|
|
||||||
CloudletName: "cloudlet1",
|
|
||||||
FlavorName: "small",
|
|
||||||
},
|
|
||||||
Desired: &InstanceState{
|
|
||||||
Name: "test-app-1.0.0-instance",
|
|
||||||
AppName: "test-app",
|
|
||||||
},
|
|
||||||
InstanceName: "test-app-1.0.0-instance",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTestManagerConfig(t *testing.T) *config.EdgeConnectConfig {
|
|
||||||
// Create temporary manifest file
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
|
|
||||||
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
|
|
||||||
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return &config.EdgeConnectConfig{
|
|
||||||
Kind: "edgeconnect-deployment",
|
|
||||||
Metadata: config.Metadata{
|
|
||||||
Name: "test-app",
|
|
||||||
AppVersion: "1.0.0",
|
|
||||||
Organization: "testorg",
|
|
||||||
},
|
|
||||||
Spec: config.Spec{
|
|
||||||
K8sApp: &config.K8sApp{
|
|
||||||
ManifestFile: manifestFile,
|
|
||||||
},
|
|
||||||
InfraTemplate: []config.InfraTemplate{
|
|
||||||
{
|
|
||||||
Region: "US",
|
|
||||||
CloudletOrg: "cloudletorg",
|
|
||||||
CloudletName: "cloudlet1",
|
|
||||||
FlavorName: "small",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Network: &config.NetworkConfig{
|
|
||||||
OutboundConnections: []config.OutboundConnection{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createTestStrategyConfig returns a fast configuration for tests
|
|
||||||
func createTestStrategyConfig() StrategyConfig {
|
|
||||||
return StrategyConfig{
|
|
||||||
MaxRetries: 0, // No retries for fast tests
|
|
||||||
HealthCheckTimeout: 1 * time.Millisecond,
|
|
||||||
ParallelOperations: false, // Sequential for predictable tests
|
|
||||||
RetryDelay: 0, // No delay
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyDeploymentSuccess(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
logger := &TestLogger{}
|
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
|
||||||
|
|
||||||
plan := createTestDeploymentPlan()
|
|
||||||
config := createTestManagerConfig(t)
|
|
||||||
|
|
||||||
// Mock successful operations
|
|
||||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
|
|
||||||
Return(nil)
|
|
||||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
|
|
||||||
Return(nil)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.True(t, result.Success)
|
|
||||||
assert.Len(t, result.CompletedActions, 2) // 1 app + 1 instance
|
|
||||||
assert.Len(t, result.FailedActions, 0)
|
|
||||||
assert.False(t, result.RollbackPerformed)
|
|
||||||
assert.Greater(t, result.Duration, time.Duration(0))
|
|
||||||
|
|
||||||
// Check that operations were logged
|
|
||||||
assert.Greater(t, len(logger.messages), 0)
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyDeploymentAppFailure(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
logger := &TestLogger{}
|
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
|
||||||
|
|
||||||
plan := createTestDeploymentPlan()
|
|
||||||
config := createTestManagerConfig(t)
|
|
||||||
|
|
||||||
// Mock app creation failure - deployment should stop here
|
|
||||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
|
|
||||||
Return(&v2.APIError{StatusCode: 500, Messages: []string{"Server error"}})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.False(t, result.Success)
|
|
||||||
assert.Len(t, result.CompletedActions, 0)
|
|
||||||
assert.Len(t, result.FailedActions, 1)
|
|
||||||
assert.Contains(t, err.Error(), "Server error")
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
logger := &TestLogger{}
|
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig()))
|
|
||||||
|
|
||||||
plan := createTestDeploymentPlan()
|
|
||||||
config := createTestManagerConfig(t)
|
|
||||||
|
|
||||||
// Mock successful app creation but failed instance creation
|
|
||||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
|
|
||||||
Return(nil)
|
|
||||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
|
|
||||||
Return(&v2.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}})
|
|
||||||
|
|
||||||
// Mock rollback operations
|
|
||||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
|
||||||
Return(nil)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.False(t, result.Success)
|
|
||||||
assert.Len(t, result.CompletedActions, 1) // App was created
|
|
||||||
assert.Len(t, result.FailedActions, 1) // Instance failed
|
|
||||||
assert.True(t, result.RollbackPerformed)
|
|
||||||
assert.True(t, result.RollbackSuccess)
|
|
||||||
assert.Contains(t, err.Error(), "failed to create instance")
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyDeploymentNoActions(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
manager := NewResourceManager(mockClient)
|
|
||||||
|
|
||||||
// Create empty plan
|
|
||||||
plan := &DeploymentPlan{
|
|
||||||
ConfigName: "empty-plan",
|
|
||||||
AppAction: AppAction{Type: ActionNone},
|
|
||||||
}
|
|
||||||
config := createTestManagerConfig(t)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.Contains(t, err.Error(), "deployment plan is empty")
|
|
||||||
|
|
||||||
mockClient.AssertNotCalled(t, "CreateApp")
|
|
||||||
mockClient.AssertNotCalled(t, "CreateAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyDeploymentMultipleInstances(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
logger := &TestLogger{}
|
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig()))
|
|
||||||
|
|
||||||
// Create plan with multiple instances
|
|
||||||
plan := &DeploymentPlan{
|
|
||||||
ConfigName: "multi-instance",
|
|
||||||
AppAction: AppAction{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Desired: &AppState{
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
Organization: "testorg",
|
|
||||||
Region: "US",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
InstanceActions: []InstanceAction{
|
|
||||||
{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Target: config.InfraTemplate{
|
|
||||||
Region: "US",
|
|
||||||
CloudletOrg: "cloudletorg1",
|
|
||||||
CloudletName: "cloudlet1",
|
|
||||||
FlavorName: "small",
|
|
||||||
},
|
|
||||||
Desired: &InstanceState{Name: "instance1"},
|
|
||||||
InstanceName: "instance1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Target: config.InfraTemplate{
|
|
||||||
Region: "EU",
|
|
||||||
CloudletOrg: "cloudletorg2",
|
|
||||||
CloudletName: "cloudlet2",
|
|
||||||
FlavorName: "medium",
|
|
||||||
},
|
|
||||||
Desired: &InstanceState{Name: "instance2"},
|
|
||||||
InstanceName: "instance2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
config := createTestManagerConfig(t)
|
|
||||||
|
|
||||||
// Mock successful operations
|
|
||||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
|
|
||||||
Return(nil)
|
|
||||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
|
|
||||||
Return(nil)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.True(t, result.Success)
|
|
||||||
assert.Len(t, result.CompletedActions, 3) // 1 app + 2 instances
|
|
||||||
assert.Len(t, result.FailedActions, 0)
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidatePrerequisites(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
manager := NewResourceManager(mockClient)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
plan *DeploymentPlan
|
|
||||||
wantErr bool
|
|
||||||
errMsg string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid plan",
|
|
||||||
plan: &DeploymentPlan{
|
|
||||||
ConfigName: "test",
|
|
||||||
AppAction: AppAction{Type: ActionCreate, Desired: &AppState{}},
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty plan",
|
|
||||||
plan: &DeploymentPlan{
|
|
||||||
ConfigName: "test",
|
|
||||||
AppAction: AppAction{Type: ActionNone},
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
errMsg: "deployment plan is empty",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
err := manager.ValidatePrerequisites(ctx, tt.plan)
|
|
||||||
|
|
||||||
if tt.wantErr {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.errMsg != "" {
|
|
||||||
assert.Contains(t, err.Error(), tt.errMsg)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRollbackDeployment(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
logger := &TestLogger{}
|
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
|
||||||
|
|
||||||
// Create result with completed actions
|
|
||||||
plan := createTestDeploymentPlan()
|
|
||||||
result := &ExecutionResult{
|
|
||||||
Plan: plan,
|
|
||||||
CompletedActions: []ActionResult{
|
|
||||||
{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Target: "test-app",
|
|
||||||
Success: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Target: "test-app-1.0.0-instance",
|
|
||||||
Success: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
FailedActions: []ActionResult{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock rollback operations
|
|
||||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
|
||||||
Return(nil)
|
|
||||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
|
||||||
Return(nil)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := manager.RollbackDeployment(ctx, result)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
|
|
||||||
// Check rollback was logged
|
|
||||||
assert.Greater(t, len(logger.messages), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRollbackDeploymentFailure(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
manager := NewResourceManager(mockClient)
|
|
||||||
|
|
||||||
plan := createTestDeploymentPlan()
|
|
||||||
result := &ExecutionResult{
|
|
||||||
Plan: plan,
|
|
||||||
CompletedActions: []ActionResult{
|
|
||||||
{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Target: "test-app",
|
|
||||||
Success: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock rollback failure
|
|
||||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
|
||||||
Return(&v2.APIError{StatusCode: 500, Messages: []string{"Delete failed"}})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := manager.RollbackDeployment(ctx, result)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "rollback encountered")
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRollbackDeploymentWithRestore(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
logger := &TestLogger{}
|
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
|
||||||
|
|
||||||
plan := createTestDeploymentPlan()
|
|
||||||
|
|
||||||
// Simulate a RecreateStrategy scenario:
|
|
||||||
// 1. Old app and instance were deleted and backed up
|
|
||||||
// 2. New app was created successfully
|
|
||||||
// 3. New instance creation failed
|
|
||||||
// 4. Rollback should: delete new app, restore old app, restore old instance
|
|
||||||
oldApp := v2.App{
|
|
||||||
Key: v2.AppKey{
|
|
||||||
Organization: "test-org",
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Deployment: "kubernetes",
|
|
||||||
DeploymentManifest: "old-manifest-content",
|
|
||||||
}
|
|
||||||
|
|
||||||
oldInstance := v2.AppInstance{
|
|
||||||
Key: v2.AppInstanceKey{
|
|
||||||
Organization: "test-org",
|
|
||||||
Name: "test-app-1.0.0-instance",
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: "test-cloudlet-org",
|
|
||||||
Name: "test-cloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppKey: v2.AppKey{
|
|
||||||
Organization: "test-org",
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Flavor: v2.Flavor{Name: "small"},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &ExecutionResult{
|
|
||||||
Plan: plan,
|
|
||||||
// Completed actions: new app was created before failure
|
|
||||||
CompletedActions: []ActionResult{
|
|
||||||
{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Target: "test-app",
|
|
||||||
Success: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Failed action: new instance creation failed
|
|
||||||
FailedActions: []ActionResult{
|
|
||||||
{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Target: "test-app-1.0.0-instance",
|
|
||||||
Success: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Backup of deleted resources
|
|
||||||
DeletedAppBackup: &AppBackup{
|
|
||||||
App: oldApp,
|
|
||||||
Region: "US",
|
|
||||||
ManifestContent: "old-manifest-content",
|
|
||||||
},
|
|
||||||
DeletedInstancesBackup: []InstanceBackup{
|
|
||||||
{
|
|
||||||
Instance: oldInstance,
|
|
||||||
Region: "US",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock rollback operations in order:
|
|
||||||
// 1. Delete newly created app (rollback create)
|
|
||||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
|
||||||
Return(nil).Once()
|
|
||||||
|
|
||||||
// 2. Restore old app (from backup)
|
|
||||||
mockClient.On("CreateApp", mock.Anything, mock.MatchedBy(func(input *v2.NewAppInput) bool {
|
|
||||||
return input.App.Key.Name == "test-app" && input.App.DeploymentManifest == "old-manifest-content"
|
|
||||||
})).Return(nil).Once()
|
|
||||||
|
|
||||||
// 3. Restore old instance (from backup)
|
|
||||||
mockClient.On("CreateAppInstance", mock.Anything, mock.MatchedBy(func(input *v2.NewAppInstanceInput) bool {
|
|
||||||
return input.AppInst.Key.Name == "test-app-1.0.0-instance"
|
|
||||||
})).Return(nil).Once()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := manager.RollbackDeployment(ctx, result)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
|
|
||||||
// Verify rollback was logged
|
|
||||||
assert.Greater(t, len(logger.messages), 0)
|
|
||||||
// Should have messages about rolling back created resources and restoring deleted resources
|
|
||||||
hasRestoreLog := false
|
|
||||||
for _, msg := range logger.messages {
|
|
||||||
if strings.Contains(msg, "Restoring deleted resources") {
|
|
||||||
hasRestoreLog = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.True(t, hasRestoreLog, "Should log restoration of deleted resources")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertNetworkRules(t *testing.T) {
|
|
||||||
network := &config.NetworkConfig{
|
|
||||||
OutboundConnections: []config.OutboundConnection{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 443,
|
|
||||||
PortRangeMax: 443,
|
|
||||||
RemoteCIDR: "10.0.0.0/8",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
rules := convertNetworkRules(network)
|
|
||||||
require.Len(t, rules, 2)
|
|
||||||
|
|
||||||
assert.Equal(t, "tcp", rules[0].Protocol)
|
|
||||||
assert.Equal(t, 80, rules[0].PortRangeMin)
|
|
||||||
assert.Equal(t, 80, rules[0].PortRangeMax)
|
|
||||||
assert.Equal(t, "0.0.0.0/0", rules[0].RemoteCIDR)
|
|
||||||
|
|
||||||
assert.Equal(t, "tcp", rules[1].Protocol)
|
|
||||||
assert.Equal(t, 443, rules[1].PortRangeMin)
|
|
||||||
assert.Equal(t, 443, rules[1].PortRangeMax)
|
|
||||||
assert.Equal(t, "10.0.0.0/8", rules[1].RemoteCIDR)
|
|
||||||
}
|
|
||||||
|
|
@ -1,663 +0,0 @@
|
||||||
// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios
|
|
||||||
// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockEdgeConnectClient is a mock implementation of the EdgeConnect client
|
|
||||||
type MockEdgeConnectClient struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) {
|
|
||||||
args := m.Called(ctx, appKey, region)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return v2.App{}, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(v2.App), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) (v2.AppInstance, error) {
|
|
||||||
args := m.Called(ctx, instanceKey, region)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return v2.AppInstance{}, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(v2.AppInstance), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *v2.NewAppInput) error {
|
|
||||||
args := m.Called(ctx, input)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error {
|
|
||||||
args := m.Called(ctx, input)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
|
|
||||||
args := m.Called(ctx, appKey, region)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error {
|
|
||||||
args := m.Called(ctx, input)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error {
|
|
||||||
args := m.Called(ctx, input)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
|
|
||||||
args := m.Called(ctx, instanceKey, region)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey v2.AppKey, region string) ([]v2.App, error) {
|
|
||||||
args := m.Called(ctx, appKey, region)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).([]v2.App), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) {
|
|
||||||
args := m.Called(ctx, instanceKey, region)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).([]v2.AppInstance), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewPlanner(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
|
|
||||||
assert.NotNil(t, planner)
|
|
||||||
assert.IsType(t, &EdgeConnectPlanner{}, planner)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultPlanOptions(t *testing.T) {
|
|
||||||
opts := DefaultPlanOptions()
|
|
||||||
|
|
||||||
assert.False(t, opts.DryRun)
|
|
||||||
assert.False(t, opts.Force)
|
|
||||||
assert.False(t, opts.SkipStateCheck)
|
|
||||||
assert.True(t, opts.ParallelQueries)
|
|
||||||
assert.Equal(t, 30*time.Second, opts.Timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTestConfig(t *testing.T) *config.EdgeConnectConfig {
|
|
||||||
// Create temporary manifest file
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
|
|
||||||
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
|
|
||||||
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return &config.EdgeConnectConfig{
|
|
||||||
Kind: "edgeconnect-deployment",
|
|
||||||
Metadata: config.Metadata{
|
|
||||||
Name: "test-app",
|
|
||||||
AppVersion: "1.0.0",
|
|
||||||
Organization: "testorg",
|
|
||||||
},
|
|
||||||
Spec: config.Spec{
|
|
||||||
K8sApp: &config.K8sApp{
|
|
||||||
ManifestFile: manifestFile,
|
|
||||||
},
|
|
||||||
InfraTemplate: []config.InfraTemplate{
|
|
||||||
{
|
|
||||||
Region: "US",
|
|
||||||
CloudletOrg: "TestCloudletOrg",
|
|
||||||
CloudletName: "TestCloudlet",
|
|
||||||
FlavorName: "small",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Network: &config.NetworkConfig{
|
|
||||||
OutboundConnections: []config.OutboundConnection{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanNewDeployment(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
// Mock API calls to return "not found" errors
|
|
||||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
|
||||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
|
||||||
|
|
||||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
|
||||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.Plan(ctx, testConfig)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.NotNil(t, result.Plan)
|
|
||||||
require.NoError(t, result.Error)
|
|
||||||
|
|
||||||
plan := result.Plan
|
|
||||||
assert.Equal(t, "test-app", plan.ConfigName)
|
|
||||||
assert.Equal(t, ActionCreate, plan.AppAction.Type)
|
|
||||||
assert.Equal(t, "Application does not exist", plan.AppAction.Reason)
|
|
||||||
|
|
||||||
require.Len(t, plan.InstanceActions, 1)
|
|
||||||
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
|
|
||||||
assert.Equal(t, "Instance does not exist", plan.InstanceActions[0].Reason)
|
|
||||||
|
|
||||||
assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance
|
|
||||||
assert.False(t, plan.IsEmpty())
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanExistingDeploymentNoChanges(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
// Note: We would calculate expected manifest hash here when API supports it
|
|
||||||
|
|
||||||
// Mock existing app with same manifest hash and outbound connections
|
|
||||||
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
|
|
||||||
existingApp := &v2.App{
|
|
||||||
Key: v2.AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Deployment: "kubernetes",
|
|
||||||
DeploymentManifest: manifestContent,
|
|
||||||
RequiredOutboundConnections: []v2.SecurityRule{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Note: Manifest hash tracking would be implemented when API supports annotations
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock existing instance
|
|
||||||
existingInstance := &v2.AppInstance{
|
|
||||||
Key: v2.AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "test-app-1.0.0-instance",
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: "TestCloudletOrg",
|
|
||||||
Name: "TestCloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppKey: v2.AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Flavor: v2.Flavor{
|
|
||||||
Name: "small",
|
|
||||||
},
|
|
||||||
State: "Ready",
|
|
||||||
PowerState: "PowerOn",
|
|
||||||
}
|
|
||||||
|
|
||||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
|
||||||
Return(*existingApp, nil)
|
|
||||||
|
|
||||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
|
||||||
Return(*existingInstance, nil)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.Plan(ctx, testConfig)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.NotNil(t, result.Plan)
|
|
||||||
|
|
||||||
plan := result.Plan
|
|
||||||
assert.Equal(t, ActionNone, plan.AppAction.Type)
|
|
||||||
assert.Len(t, plan.InstanceActions, 1)
|
|
||||||
assert.Equal(t, ActionNone, plan.InstanceActions[0].Type)
|
|
||||||
assert.Equal(t, 0, plan.TotalActions)
|
|
||||||
assert.True(t, plan.IsEmpty())
|
|
||||||
assert.Contains(t, plan.Summary, "No changes required")
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanWithOptions(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
opts := PlanOptions{
|
|
||||||
DryRun: true,
|
|
||||||
SkipStateCheck: true,
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.PlanWithOptions(ctx, testConfig, opts)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.NotNil(t, result.Plan)
|
|
||||||
|
|
||||||
plan := result.Plan
|
|
||||||
assert.True(t, plan.DryRun)
|
|
||||||
assert.Equal(t, ActionCreate, plan.AppAction.Type)
|
|
||||||
assert.Contains(t, plan.AppAction.Reason, "state check skipped")
|
|
||||||
|
|
||||||
// No API calls should be made when SkipStateCheck is true
|
|
||||||
mockClient.AssertNotCalled(t, "ShowApp")
|
|
||||||
mockClient.AssertNotCalled(t, "ShowAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanMultipleInfrastructures(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
// Add a second infrastructure target
|
|
||||||
testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{
|
|
||||||
Region: "EU",
|
|
||||||
CloudletOrg: "EUCloudletOrg",
|
|
||||||
CloudletName: "EUCloudlet",
|
|
||||||
FlavorName: "medium",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mock API calls to return "not found" errors
|
|
||||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
|
||||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
|
||||||
|
|
||||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
|
||||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
|
|
||||||
|
|
||||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "EU").
|
|
||||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.Plan(ctx, testConfig)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.NotNil(t, result.Plan)
|
|
||||||
|
|
||||||
plan := result.Plan
|
|
||||||
assert.Equal(t, ActionCreate, plan.AppAction.Type)
|
|
||||||
|
|
||||||
// Should have 2 instance actions, one for each infrastructure
|
|
||||||
require.Len(t, plan.InstanceActions, 2)
|
|
||||||
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
|
|
||||||
assert.Equal(t, ActionCreate, plan.InstanceActions[1].Type)
|
|
||||||
|
|
||||||
assert.Equal(t, 3, plan.TotalActions) // 1 app + 2 instances
|
|
||||||
|
|
||||||
// Test cloudlet and region aggregation
|
|
||||||
cloudlets := plan.GetTargetCloudlets()
|
|
||||||
regions := plan.GetTargetRegions()
|
|
||||||
assert.Len(t, cloudlets, 2)
|
|
||||||
assert.Len(t, regions, 2)
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculateManifestHash(t *testing.T) {
|
|
||||||
planner := &EdgeConnectPlanner{}
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
// Create test file
|
|
||||||
testFile := filepath.Join(tempDir, "test.yaml")
|
|
||||||
content := "test content for hashing"
|
|
||||||
err := os.WriteFile(testFile, []byte(content), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
hash1, err := planner.calculateManifestHash(testFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotEmpty(t, hash1)
|
|
||||||
assert.Len(t, hash1, 64) // SHA256 hex string length
|
|
||||||
|
|
||||||
// Same content should produce same hash
|
|
||||||
hash2, err := planner.calculateManifestHash(testFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, hash1, hash2)
|
|
||||||
|
|
||||||
// Different content should produce different hash
|
|
||||||
err = os.WriteFile(testFile, []byte("different content"), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
hash3, err := planner.calculateManifestHash(testFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotEqual(t, hash1, hash3)
|
|
||||||
|
|
||||||
// Empty file path should return empty hash
|
|
||||||
hash4, err := planner.calculateManifestHash("")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, hash4)
|
|
||||||
|
|
||||||
// Non-existent file should return error
|
|
||||||
_, err = planner.calculateManifestHash("/non/existent/file")
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareAppStates(t *testing.T) {
|
|
||||||
planner := &EdgeConnectPlanner{}
|
|
||||||
|
|
||||||
current := &AppState{
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
AppType: AppTypeK8s,
|
|
||||||
ManifestHash: "old-hash",
|
|
||||||
}
|
|
||||||
|
|
||||||
desired := &AppState{
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
AppType: AppTypeK8s,
|
|
||||||
ManifestHash: "new-hash",
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, manifestChanged := planner.compareAppStates(current, desired)
|
|
||||||
assert.Len(t, changes, 1)
|
|
||||||
assert.True(t, manifestChanged)
|
|
||||||
assert.Contains(t, changes[0], "Manifest hash changed")
|
|
||||||
|
|
||||||
// Test no changes
|
|
||||||
desired.ManifestHash = "old-hash"
|
|
||||||
changes, manifestChanged = planner.compareAppStates(current, desired)
|
|
||||||
assert.Empty(t, changes)
|
|
||||||
assert.False(t, manifestChanged)
|
|
||||||
|
|
||||||
// Test app type change
|
|
||||||
desired.AppType = AppTypeDocker
|
|
||||||
changes, manifestChanged = planner.compareAppStates(current, desired)
|
|
||||||
assert.Len(t, changes, 1)
|
|
||||||
assert.False(t, manifestChanged)
|
|
||||||
assert.Contains(t, changes[0], "App type changed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareAppStatesOutboundConnections(t *testing.T) {
|
|
||||||
planner := &EdgeConnectPlanner{}
|
|
||||||
|
|
||||||
// Test with no outbound connections
|
|
||||||
current := &AppState{
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
AppType: AppTypeK8s,
|
|
||||||
OutboundConnections: nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
desired := &AppState{
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
AppType: AppTypeK8s,
|
|
||||||
OutboundConnections: nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, _ := planner.compareAppStates(current, desired)
|
|
||||||
assert.Empty(t, changes, "No changes expected when both have no outbound connections")
|
|
||||||
|
|
||||||
// Test adding outbound connections
|
|
||||||
desired.OutboundConnections = []SecurityRule{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, _ = planner.compareAppStates(current, desired)
|
|
||||||
assert.Len(t, changes, 1)
|
|
||||||
assert.Contains(t, changes[0], "Outbound connections changed")
|
|
||||||
|
|
||||||
// Test identical outbound connections
|
|
||||||
current.OutboundConnections = []SecurityRule{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, _ = planner.compareAppStates(current, desired)
|
|
||||||
assert.Empty(t, changes, "No changes expected when outbound connections are identical")
|
|
||||||
|
|
||||||
// Test different outbound connections (different port)
|
|
||||||
desired.OutboundConnections[0].PortRangeMin = 443
|
|
||||||
desired.OutboundConnections[0].PortRangeMax = 443
|
|
||||||
|
|
||||||
changes, _ = planner.compareAppStates(current, desired)
|
|
||||||
assert.Len(t, changes, 1)
|
|
||||||
assert.Contains(t, changes[0], "Outbound connections changed")
|
|
||||||
|
|
||||||
// Test same connections but different order (should be considered equal)
|
|
||||||
current.OutboundConnections = []SecurityRule{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 443,
|
|
||||||
PortRangeMax: 443,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
desired.OutboundConnections = []SecurityRule{
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 443,
|
|
||||||
PortRangeMax: 443,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Protocol: "tcp",
|
|
||||||
PortRangeMin: 80,
|
|
||||||
PortRangeMax: 80,
|
|
||||||
RemoteCIDR: "0.0.0.0/0",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, _ = planner.compareAppStates(current, desired)
|
|
||||||
assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order")
|
|
||||||
|
|
||||||
// Test removing outbound connections
|
|
||||||
desired.OutboundConnections = nil
|
|
||||||
|
|
||||||
changes, _ = planner.compareAppStates(current, desired)
|
|
||||||
assert.Len(t, changes, 1)
|
|
||||||
assert.Contains(t, changes[0], "Outbound connections changed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareInstanceStates(t *testing.T) {
|
|
||||||
planner := &EdgeConnectPlanner{}
|
|
||||||
|
|
||||||
current := &InstanceState{
|
|
||||||
Name: "test-instance",
|
|
||||||
FlavorName: "small",
|
|
||||||
CloudletName: "oldcloudlet",
|
|
||||||
CloudletOrg: "oldorg",
|
|
||||||
}
|
|
||||||
|
|
||||||
desired := &InstanceState{
|
|
||||||
Name: "test-instance",
|
|
||||||
FlavorName: "medium",
|
|
||||||
CloudletName: "newcloudlet",
|
|
||||||
CloudletOrg: "neworg",
|
|
||||||
}
|
|
||||||
|
|
||||||
changes := planner.compareInstanceStates(current, desired)
|
|
||||||
assert.Len(t, changes, 3)
|
|
||||||
assert.Contains(t, changes[0], "Flavor changed")
|
|
||||||
assert.Contains(t, changes[1], "Cloudlet changed")
|
|
||||||
assert.Contains(t, changes[2], "Cloudlet org changed")
|
|
||||||
|
|
||||||
// Test no changes
|
|
||||||
desired.FlavorName = "small"
|
|
||||||
desired.CloudletName = "oldcloudlet"
|
|
||||||
desired.CloudletOrg = "oldorg"
|
|
||||||
changes = planner.compareInstanceStates(current, desired)
|
|
||||||
assert.Empty(t, changes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeploymentPlanMethods(t *testing.T) {
|
|
||||||
plan := &DeploymentPlan{
|
|
||||||
ConfigName: "test-plan",
|
|
||||||
AppAction: AppAction{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Desired: &AppState{Name: "test-app"},
|
|
||||||
},
|
|
||||||
InstanceActions: []InstanceAction{
|
|
||||||
{
|
|
||||||
Type: ActionCreate,
|
|
||||||
Target: config.InfraTemplate{
|
|
||||||
CloudletOrg: "org1",
|
|
||||||
CloudletName: "cloudlet1",
|
|
||||||
Region: "US",
|
|
||||||
},
|
|
||||||
InstanceName: "instance1",
|
|
||||||
Desired: &InstanceState{Name: "instance1"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: ActionUpdate,
|
|
||||||
Target: config.InfraTemplate{
|
|
||||||
CloudletOrg: "org2",
|
|
||||||
CloudletName: "cloudlet2",
|
|
||||||
Region: "EU",
|
|
||||||
},
|
|
||||||
InstanceName: "instance2",
|
|
||||||
Desired: &InstanceState{Name: "instance2"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test IsEmpty
|
|
||||||
assert.False(t, plan.IsEmpty())
|
|
||||||
|
|
||||||
// Test GetTargetCloudlets
|
|
||||||
cloudlets := plan.GetTargetCloudlets()
|
|
||||||
assert.Len(t, cloudlets, 2)
|
|
||||||
assert.Contains(t, cloudlets, "org1:cloudlet1")
|
|
||||||
assert.Contains(t, cloudlets, "org2:cloudlet2")
|
|
||||||
|
|
||||||
// Test GetTargetRegions
|
|
||||||
regions := plan.GetTargetRegions()
|
|
||||||
assert.Len(t, regions, 2)
|
|
||||||
assert.Contains(t, regions, "US")
|
|
||||||
assert.Contains(t, regions, "EU")
|
|
||||||
|
|
||||||
// Test GenerateSummary
|
|
||||||
summary := plan.GenerateSummary()
|
|
||||||
assert.Contains(t, summary, "test-plan")
|
|
||||||
assert.Contains(t, summary, "CREATE application")
|
|
||||||
assert.Contains(t, summary, "CREATE 1 instance")
|
|
||||||
assert.Contains(t, summary, "UPDATE 1 instance")
|
|
||||||
|
|
||||||
// Test Validate
|
|
||||||
err := plan.Validate()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test validation failure
|
|
||||||
plan.AppAction.Desired = nil
|
|
||||||
err = plan.Validate()
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "must have desired state")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEstimateDeploymentDuration(t *testing.T) {
|
|
||||||
planner := &EdgeConnectPlanner{}
|
|
||||||
|
|
||||||
plan := &DeploymentPlan{
|
|
||||||
AppAction: AppAction{Type: ActionCreate},
|
|
||||||
InstanceActions: []InstanceAction{
|
|
||||||
{Type: ActionCreate},
|
|
||||||
{Type: ActionUpdate},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
duration := planner.estimateDeploymentDuration(plan)
|
|
||||||
assert.Greater(t, duration, time.Duration(0))
|
|
||||||
assert.Less(t, duration, 10*time.Minute) // Reasonable upper bound
|
|
||||||
|
|
||||||
// Test with no actions
|
|
||||||
emptyPlan := &DeploymentPlan{
|
|
||||||
AppAction: AppAction{Type: ActionNone},
|
|
||||||
InstanceActions: []InstanceAction{},
|
|
||||||
}
|
|
||||||
|
|
||||||
emptyDuration := planner.estimateDeploymentDuration(emptyPlan)
|
|
||||||
assert.Greater(t, emptyDuration, time.Duration(0))
|
|
||||||
assert.Less(t, emptyDuration, duration) // Should be less than plan with actions
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsResourceNotFoundError(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"nil error", nil, false},
|
|
||||||
{"not found error", &v2.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true},
|
|
||||||
{"does not exist error", &v2.APIError{Messages: []string{"App does not exist"}}, true},
|
|
||||||
{"404 in message", &v2.APIError{Messages: []string{"HTTP 404 error"}}, true},
|
|
||||||
{"other error", &v2.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := isResourceNotFoundError(tt.err)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanErrorHandling(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
// Mock API call to return a non-404 error
|
|
||||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
|
||||||
Return(nil, &v2.APIError{StatusCode: 500, Messages: []string{"Server error"}})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.Plan(ctx, testConfig)
|
|
||||||
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.NotNil(t, result)
|
|
||||||
assert.NotNil(t, result.Error)
|
|
||||||
assert.Contains(t, err.Error(), "failed to query current app state")
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
@ -1,641 +0,0 @@
|
||||||
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect
|
|
||||||
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RecreateStrategy implements the recreate deployment strategy
|
|
||||||
type RecreateStrategy struct {
|
|
||||||
client EdgeConnectClientInterface
|
|
||||||
config StrategyConfig
|
|
||||||
logger Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRecreateStrategy creates a new recreate strategy executor
|
|
||||||
func NewRecreateStrategy(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *RecreateStrategy {
|
|
||||||
return &RecreateStrategy{
|
|
||||||
client: client,
|
|
||||||
config: config,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetName returns the strategy name
|
|
||||||
func (r *RecreateStrategy) GetName() DeploymentStrategy {
|
|
||||||
return StrategyRecreate
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate checks if the recreate strategy can be used for this deployment
|
|
||||||
func (r *RecreateStrategy) Validate(plan *DeploymentPlan) error {
|
|
||||||
// Recreate strategy can be used for any deployment
|
|
||||||
// No specific constraints for recreate
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EstimateDuration estimates the time needed for recreate deployment
|
|
||||||
func (r *RecreateStrategy) EstimateDuration(plan *DeploymentPlan) time.Duration {
|
|
||||||
var duration time.Duration
|
|
||||||
|
|
||||||
// Delete phase - estimate based on number of instances
|
|
||||||
instanceCount := len(plan.InstanceActions)
|
|
||||||
if instanceCount > 0 {
|
|
||||||
deleteTime := time.Duration(instanceCount) * 30 * time.Second
|
|
||||||
if r.config.ParallelOperations {
|
|
||||||
deleteTime = 30 * time.Second // Parallel deletion
|
|
||||||
}
|
|
||||||
duration += deleteTime
|
|
||||||
}
|
|
||||||
|
|
||||||
// App update phase
|
|
||||||
if plan.AppAction.Type == ActionUpdate {
|
|
||||||
duration += 30 * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create phase - estimate based on number of instances
|
|
||||||
if instanceCount > 0 {
|
|
||||||
createTime := time.Duration(instanceCount) * 2 * time.Minute
|
|
||||||
if r.config.ParallelOperations {
|
|
||||||
createTime = 2 * time.Minute // Parallel creation
|
|
||||||
}
|
|
||||||
duration += createTime
|
|
||||||
}
|
|
||||||
|
|
||||||
// Health check time
|
|
||||||
duration += r.config.HealthCheckTimeout
|
|
||||||
|
|
||||||
// Add retry buffer (potential retries)
|
|
||||||
retryBuffer := time.Duration(r.config.MaxRetries) * r.config.RetryDelay
|
|
||||||
duration += retryBuffer
|
|
||||||
|
|
||||||
return duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute runs the recreate deployment strategy
|
|
||||||
func (r *RecreateStrategy) Execute(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) {
|
|
||||||
startTime := time.Now()
|
|
||||||
r.logf("Starting recreate deployment strategy for: %s", plan.ConfigName)
|
|
||||||
|
|
||||||
result := &ExecutionResult{
|
|
||||||
Plan: plan,
|
|
||||||
CompletedActions: []ActionResult{},
|
|
||||||
FailedActions: []ActionResult{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 1: Delete all existing instances
|
|
||||||
if err := r.deleteInstancesPhase(ctx, plan, config, result); err != nil {
|
|
||||||
result.Error = err
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Delete existing app (if updating)
|
|
||||||
if err := r.deleteAppPhase(ctx, plan, config, result); err != nil {
|
|
||||||
result.Error = err
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: Create/recreate application
|
|
||||||
if err := r.createAppPhase(ctx, plan, config, manifestContent, result); err != nil {
|
|
||||||
result.Error = err
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 4: Create new instances
|
|
||||||
if err := r.createInstancesPhase(ctx, plan, config, result); err != nil {
|
|
||||||
result.Error = err
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 5: Health check (wait for instances to be ready)
|
|
||||||
if err := r.healthCheckPhase(ctx, plan, result); err != nil {
|
|
||||||
result.Error = err
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Success = len(result.FailedActions) == 0
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
|
|
||||||
if result.Success {
|
|
||||||
r.logf("Recreate deployment completed successfully in %v", result.Duration)
|
|
||||||
} else {
|
|
||||||
r.logf("Recreate deployment failed with %d failed actions", len(result.FailedActions))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteInstancesPhase deletes all existing instances
|
|
||||||
func (r *RecreateStrategy) deleteInstancesPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
|
|
||||||
r.logf("Phase 1: Deleting existing instances")
|
|
||||||
|
|
||||||
// Only delete instances that exist (have ActionUpdate or ActionNone type)
|
|
||||||
instancesToDelete := []InstanceAction{}
|
|
||||||
for _, action := range plan.InstanceActions {
|
|
||||||
if action.Type == ActionUpdate || action.Type == ActionNone {
|
|
||||||
// Convert to delete action
|
|
||||||
deleteAction := action
|
|
||||||
deleteAction.Type = ActionDelete
|
|
||||||
deleteAction.Reason = "Recreate strategy: deleting for recreation"
|
|
||||||
instancesToDelete = append(instancesToDelete, deleteAction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(instancesToDelete) == 0 {
|
|
||||||
r.logf("No existing instances to delete")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backup instances before deleting them (for rollback restoration)
|
|
||||||
r.logf("Backing up %d existing instances before deletion", len(instancesToDelete))
|
|
||||||
for _, action := range instancesToDelete {
|
|
||||||
backup, err := r.backupInstance(ctx, action, config)
|
|
||||||
if err != nil {
|
|
||||||
r.logf("Warning: failed to backup instance %s before deletion: %v", action.InstanceName, err)
|
|
||||||
// Continue with deletion even if backup fails - this is best effort
|
|
||||||
} else {
|
|
||||||
result.DeletedInstancesBackup = append(result.DeletedInstancesBackup, *backup)
|
|
||||||
r.logf("Backed up instance: %s", action.InstanceName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteResults := r.executeInstanceActionsWithRetry(ctx, instancesToDelete, "delete", config)
|
|
||||||
|
|
||||||
for _, deleteResult := range deleteResults {
|
|
||||||
if deleteResult.Success {
|
|
||||||
result.CompletedActions = append(result.CompletedActions, deleteResult)
|
|
||||||
r.logf("Deleted instance: %s", deleteResult.Target)
|
|
||||||
} else {
|
|
||||||
result.FailedActions = append(result.FailedActions, deleteResult)
|
|
||||||
return fmt.Errorf("failed to delete instance %s: %w", deleteResult.Target, deleteResult.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r.logf("Phase 1 complete: deleted %d instances", len(deleteResults))
|
|
||||||
|
|
||||||
// Wait for Kubernetes namespace termination to complete
|
|
||||||
// This prevents "namespace is being terminated" errors when recreating instances
|
|
||||||
if len(deleteResults) > 0 {
|
|
||||||
waitTime := 5 * time.Second
|
|
||||||
r.logf("Waiting %v for namespace termination to complete...", waitTime)
|
|
||||||
select {
|
|
||||||
case <-time.After(waitTime):
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteAppPhase deletes the existing app (if updating)
|
|
||||||
func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
|
|
||||||
if plan.AppAction.Type != ActionUpdate {
|
|
||||||
r.logf("Phase 2: No app deletion needed (new app)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
r.logf("Phase 2: Deleting existing application")
|
|
||||||
|
|
||||||
// Backup app before deleting it (for rollback restoration)
|
|
||||||
r.logf("Backing up existing app before deletion")
|
|
||||||
backup, err := r.backupApp(ctx, plan, config)
|
|
||||||
if err != nil {
|
|
||||||
r.logf("Warning: failed to backup app before deletion: %v", err)
|
|
||||||
// Continue with deletion even if backup fails - this is best effort
|
|
||||||
} else {
|
|
||||||
result.DeletedAppBackup = backup
|
|
||||||
r.logf("Backed up app: %s", plan.AppAction.Desired.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
appKey := v2.AppKey{
|
|
||||||
Organization: plan.AppAction.Desired.Organization,
|
|
||||||
Name: plan.AppAction.Desired.Name,
|
|
||||||
Version: plan.AppAction.Desired.Version,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region); err != nil {
|
|
||||||
result.FailedActions = append(result.FailedActions, ActionResult{
|
|
||||||
Type: ActionDelete,
|
|
||||||
Target: plan.AppAction.Desired.Name,
|
|
||||||
Success: false,
|
|
||||||
Error: err,
|
|
||||||
})
|
|
||||||
return fmt.Errorf("failed to delete app: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.CompletedActions = append(result.CompletedActions, ActionResult{
|
|
||||||
Type: ActionDelete,
|
|
||||||
Target: plan.AppAction.Desired.Name,
|
|
||||||
Success: true,
|
|
||||||
Details: fmt.Sprintf("Deleted app %s", plan.AppAction.Desired.Name),
|
|
||||||
})
|
|
||||||
|
|
||||||
r.logf("Phase 2 complete: deleted existing application")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createAppPhase creates the application (always create since we deleted it first)
|
|
||||||
func (r *RecreateStrategy) createAppPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string, result *ExecutionResult) error {
|
|
||||||
if plan.AppAction.Type == ActionNone {
|
|
||||||
r.logf("Phase 3: No app creation needed")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
r.logf("Phase 3: Creating application")
|
|
||||||
|
|
||||||
// Always use create since recreate strategy deletes first
|
|
||||||
createAction := plan.AppAction
|
|
||||||
createAction.Type = ActionCreate
|
|
||||||
createAction.Reason = "Recreate strategy: creating app"
|
|
||||||
|
|
||||||
appResult := r.executeAppActionWithRetry(ctx, createAction, config, manifestContent)
|
|
||||||
|
|
||||||
if appResult.Success {
|
|
||||||
result.CompletedActions = append(result.CompletedActions, appResult)
|
|
||||||
r.logf("Phase 3 complete: app created successfully")
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
result.FailedActions = append(result.FailedActions, appResult)
|
|
||||||
return fmt.Errorf("failed to create app: %w", appResult.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createInstancesPhase creates new instances
|
|
||||||
func (r *RecreateStrategy) createInstancesPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
|
|
||||||
r.logf("Phase 4: Creating new instances")
|
|
||||||
|
|
||||||
// Convert all instance actions to create
|
|
||||||
instancesToCreate := []InstanceAction{}
|
|
||||||
for _, action := range plan.InstanceActions {
|
|
||||||
createAction := action
|
|
||||||
createAction.Type = ActionCreate
|
|
||||||
createAction.Reason = "Recreate strategy: creating new instance"
|
|
||||||
instancesToCreate = append(instancesToCreate, createAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(instancesToCreate) == 0 {
|
|
||||||
r.logf("No instances to create")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
createResults := r.executeInstanceActionsWithRetry(ctx, instancesToCreate, "create", config)
|
|
||||||
|
|
||||||
for _, createResult := range createResults {
|
|
||||||
if createResult.Success {
|
|
||||||
result.CompletedActions = append(result.CompletedActions, createResult)
|
|
||||||
r.logf("Created instance: %s", createResult.Target)
|
|
||||||
} else {
|
|
||||||
result.FailedActions = append(result.FailedActions, createResult)
|
|
||||||
return fmt.Errorf("failed to create instance %s: %w", createResult.Target, createResult.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r.logf("Phase 4 complete: created %d instances", len(createResults))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// healthCheckPhase waits for instances to become ready
|
|
||||||
func (r *RecreateStrategy) healthCheckPhase(ctx context.Context, plan *DeploymentPlan, result *ExecutionResult) error {
|
|
||||||
if len(plan.InstanceActions) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
r.logf("Phase 5: Performing health checks")
|
|
||||||
|
|
||||||
// TODO: Implement actual health checks by querying instance status
|
|
||||||
// For now, skip waiting in tests/mock environments
|
|
||||||
r.logf("Phase 5 complete: health check passed (no wait)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeInstanceActionsWithRetry executes instance actions with retry logic
|
|
||||||
func (r *RecreateStrategy) executeInstanceActionsWithRetry(ctx context.Context, actions []InstanceAction, operation string, config *config.EdgeConnectConfig) []ActionResult {
|
|
||||||
results := make([]ActionResult, len(actions))
|
|
||||||
|
|
||||||
if r.config.ParallelOperations && len(actions) > 1 {
|
|
||||||
// Parallel execution
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
semaphore := make(chan struct{}, 5) // Limit concurrency
|
|
||||||
|
|
||||||
for i, action := range actions {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(index int, instanceAction InstanceAction) {
|
|
||||||
defer wg.Done()
|
|
||||||
semaphore <- struct{}{}
|
|
||||||
defer func() { <-semaphore }()
|
|
||||||
|
|
||||||
results[index] = r.executeInstanceActionWithRetry(ctx, instanceAction, operation, config)
|
|
||||||
}(i, action)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
} else {
|
|
||||||
// Sequential execution
|
|
||||||
for i, action := range actions {
|
|
||||||
results[i] = r.executeInstanceActionWithRetry(ctx, action, operation, config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeInstanceActionWithRetry executes a single instance action with retry logic
|
|
||||||
func (r *RecreateStrategy) executeInstanceActionWithRetry(ctx context.Context, action InstanceAction, operation string, config *config.EdgeConnectConfig) ActionResult {
|
|
||||||
startTime := time.Now()
|
|
||||||
result := ActionResult{
|
|
||||||
Type: action.Type,
|
|
||||||
Target: action.InstanceName,
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
r.logf("Retrying %s for instance %s (attempt %d/%d)", operation, action.InstanceName, attempt, r.config.MaxRetries)
|
|
||||||
select {
|
|
||||||
case <-time.After(r.config.RetryDelay):
|
|
||||||
case <-ctx.Done():
|
|
||||||
result.Error = ctx.Err()
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var success bool
|
|
||||||
var err error
|
|
||||||
|
|
||||||
switch action.Type {
|
|
||||||
case ActionDelete:
|
|
||||||
success, err = r.deleteInstance(ctx, action)
|
|
||||||
case ActionCreate:
|
|
||||||
success, err = r.createInstance(ctx, action, config)
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("unsupported action type: %s", action.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if success {
|
|
||||||
result.Success = true
|
|
||||||
result.Details = fmt.Sprintf("Successfully %sd instance %s", strings.ToLower(string(action.Type)), action.InstanceName)
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
lastErr = err
|
|
||||||
|
|
||||||
// Check if error is retryable (don't retry 4xx client errors)
|
|
||||||
if !isRetryableError(err) {
|
|
||||||
r.logf("Failed to %s instance %s: %v (non-retryable error, giving up)", operation, action.InstanceName, err)
|
|
||||||
result.Error = fmt.Errorf("non-retryable error: %w", err)
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if attempt < r.config.MaxRetries {
|
|
||||||
r.logf("Failed to %s instance %s: %v (will retry)", operation, action.InstanceName, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Error = fmt.Errorf("failed after %d attempts: %w", r.config.MaxRetries+1, lastErr)
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeAppActionWithRetry executes app action with retry logic
|
|
||||||
func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) ActionResult {
|
|
||||||
startTime := time.Now()
|
|
||||||
result := ActionResult{
|
|
||||||
Type: action.Type,
|
|
||||||
Target: action.Desired.Name,
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
r.logf("Retrying app update (attempt %d/%d)", attempt, r.config.MaxRetries)
|
|
||||||
select {
|
|
||||||
case <-time.After(r.config.RetryDelay):
|
|
||||||
case <-ctx.Done():
|
|
||||||
result.Error = ctx.Err()
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
success, err := r.updateApplication(ctx, action, config, manifestContent)
|
|
||||||
if success {
|
|
||||||
result.Success = true
|
|
||||||
result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name)
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
lastErr = err
|
|
||||||
|
|
||||||
// Check if error is retryable (don't retry 4xx client errors)
|
|
||||||
if !isRetryableError(err) {
|
|
||||||
r.logf("Failed to update app: %v (non-retryable error, giving up)", err)
|
|
||||||
result.Error = fmt.Errorf("non-retryable error: %w", err)
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if attempt < r.config.MaxRetries {
|
|
||||||
r.logf("Failed to update app: %v (will retry)", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Error = fmt.Errorf("failed after %d attempts: %w", r.config.MaxRetries+1, lastErr)
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteInstance deletes an instance (reuse existing logic from manager.go)
|
|
||||||
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
|
|
||||||
instanceKey := v2.AppInstanceKey{
|
|
||||||
Organization: action.Desired.Organization,
|
|
||||||
Name: action.InstanceName,
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: action.Target.CloudletOrg,
|
|
||||||
Name: action.Target.CloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := r.client.DeleteAppInstance(ctx, instanceKey, action.Target.Region)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to delete instance: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createInstance creates an instance (extracted from manager.go logic)
|
|
||||||
func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
|
|
||||||
instanceInput := &v2.NewAppInstanceInput{
|
|
||||||
Region: action.Target.Region,
|
|
||||||
AppInst: v2.AppInstance{
|
|
||||||
Key: v2.AppInstanceKey{
|
|
||||||
Organization: action.Desired.Organization,
|
|
||||||
Name: action.InstanceName,
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: action.Target.CloudletOrg,
|
|
||||||
Name: action.Target.CloudletName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppKey: v2.AppKey{
|
|
||||||
Organization: action.Desired.Organization,
|
|
||||||
Name: config.Metadata.Name,
|
|
||||||
Version: config.Metadata.AppVersion,
|
|
||||||
},
|
|
||||||
Flavor: v2.Flavor{
|
|
||||||
Name: action.Target.FlavorName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the instance
|
|
||||||
if err := r.client.CreateAppInstance(ctx, instanceInput); err != nil {
|
|
||||||
return false, fmt.Errorf("failed to create instance: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.logf("Successfully created instance: %s on %s:%s",
|
|
||||||
action.InstanceName, action.Target.CloudletOrg, action.Target.CloudletName)
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateApplication creates/recreates an application (always uses CreateApp since we delete first)
|
|
||||||
func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) {
|
|
||||||
// Build the app create input - always create since recreate strategy deletes first
|
|
||||||
appInput := &v2.NewAppInput{
|
|
||||||
Region: action.Desired.Region,
|
|
||||||
App: v2.App{
|
|
||||||
Key: v2.AppKey{
|
|
||||||
Organization: action.Desired.Organization,
|
|
||||||
Name: action.Desired.Name,
|
|
||||||
Version: action.Desired.Version,
|
|
||||||
},
|
|
||||||
Deployment: config.GetDeploymentType(),
|
|
||||||
ImageType: "ImageTypeDocker",
|
|
||||||
ImagePath: config.GetImagePath(),
|
|
||||||
AllowServerless: true,
|
|
||||||
DefaultFlavor: v2.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
|
|
||||||
ServerlessConfig: struct{}{},
|
|
||||||
DeploymentManifest: manifestContent,
|
|
||||||
DeploymentGenerator: "kubernetes-basic",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add network configuration if specified
|
|
||||||
if config.Spec.Network != nil {
|
|
||||||
appInput.App.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the application (recreate strategy always creates from scratch)
|
|
||||||
if err := r.client.CreateApp(ctx, appInput); err != nil {
|
|
||||||
return false, fmt.Errorf("failed to create application: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.logf("Successfully created application: %s/%s version %s",
|
|
||||||
action.Desired.Organization, action.Desired.Name, action.Desired.Version)
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// backupApp fetches and stores the current app state before deletion
|
|
||||||
func (r *RecreateStrategy) backupApp(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig) (*AppBackup, error) {
|
|
||||||
appKey := v2.AppKey{
|
|
||||||
Organization: plan.AppAction.Desired.Organization,
|
|
||||||
Name: plan.AppAction.Desired.Name,
|
|
||||||
Version: plan.AppAction.Desired.Version,
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := r.client.ShowApp(ctx, appKey, plan.AppAction.Desired.Region)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch app for backup: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
backup := &AppBackup{
|
|
||||||
App: app,
|
|
||||||
Region: plan.AppAction.Desired.Region,
|
|
||||||
ManifestContent: app.DeploymentManifest,
|
|
||||||
}
|
|
||||||
|
|
||||||
return backup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// backupInstance fetches and stores the current instance state before deletion
|
|
||||||
func (r *RecreateStrategy) backupInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (*InstanceBackup, error) {
|
|
||||||
instanceKey := v2.AppInstanceKey{
|
|
||||||
Organization: action.Desired.Organization,
|
|
||||||
Name: action.InstanceName,
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: action.Target.CloudletOrg,
|
|
||||||
Name: action.Target.CloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
appKey := v2.AppKey{Name: action.Desired.AppName}
|
|
||||||
|
|
||||||
instance, err := r.client.ShowAppInstance(ctx, instanceKey, appKey, action.Target.Region)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch instance for backup: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
backup := &InstanceBackup{
|
|
||||||
Instance: instance,
|
|
||||||
Region: action.Target.Region,
|
|
||||||
}
|
|
||||||
|
|
||||||
return backup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// logf logs a message if a logger is configured
|
|
||||||
func (r *RecreateStrategy) logf(format string, v ...interface{}) {
|
|
||||||
if r.logger != nil {
|
|
||||||
r.logger.Printf("[RecreateStrategy] "+format, v...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isRetryableError determines if an error should be retried
|
|
||||||
// Returns false for client errors (4xx), true for server errors (5xx) and other transient errors
|
|
||||||
func isRetryableError(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
errStr := strings.ToLower(err.Error())
|
|
||||||
|
|
||||||
// Special case: Kubernetes namespace termination race condition
|
|
||||||
// This is a transient 400 error that should be retried
|
|
||||||
if strings.Contains(errStr, "being terminated") || strings.Contains(errStr, "is being terminated") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's an APIError with a status code
|
|
||||||
var apiErr *v2.APIError
|
|
||||||
if errors.As(err, &apiErr) {
|
|
||||||
// Don't retry client errors (4xx)
|
|
||||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Retry server errors (5xx)
|
|
||||||
if apiErr.StatusCode >= 500 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry all other errors (network issues, timeouts, etc.)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
@ -1,489 +0,0 @@
|
||||||
// ABOUTME: Deployment planning types for EdgeConnect apply command with state management
|
|
||||||
// ABOUTME: Defines structures for deployment plans, actions, and state comparison results
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SecurityRule defines network access rules (alias to SDK type for consistency)
|
|
||||||
type SecurityRule = v2.SecurityRule
|
|
||||||
|
|
||||||
// ActionType represents the type of action to be performed
|
|
||||||
type ActionType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ActionCreate indicates a resource needs to be created
|
|
||||||
ActionCreate ActionType = "CREATE"
|
|
||||||
// ActionUpdate indicates a resource needs to be updated
|
|
||||||
ActionUpdate ActionType = "UPDATE"
|
|
||||||
// ActionNone indicates no action is needed
|
|
||||||
ActionNone ActionType = "NONE"
|
|
||||||
// ActionDelete indicates a resource needs to be deleted (for rollback scenarios)
|
|
||||||
ActionDelete ActionType = "DELETE"
|
|
||||||
)
|
|
||||||
|
|
||||||
// String returns the string representation of ActionType
|
|
||||||
func (a ActionType) String() string {
|
|
||||||
return string(a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeploymentPlan represents the complete deployment plan for a configuration
|
|
||||||
type DeploymentPlan struct {
|
|
||||||
// ConfigName is the name from metadata
|
|
||||||
ConfigName string
|
|
||||||
|
|
||||||
// AppAction defines what needs to be done with the application
|
|
||||||
AppAction AppAction
|
|
||||||
|
|
||||||
// InstanceActions defines what needs to be done with each instance
|
|
||||||
InstanceActions []InstanceAction
|
|
||||||
|
|
||||||
// Summary provides a human-readable summary of the plan
|
|
||||||
Summary string
|
|
||||||
|
|
||||||
// TotalActions is the count of all actions that will be performed
|
|
||||||
TotalActions int
|
|
||||||
|
|
||||||
// EstimatedDuration is the estimated time to complete the deployment
|
|
||||||
EstimatedDuration time.Duration
|
|
||||||
|
|
||||||
// CreatedAt timestamp when the plan was created
|
|
||||||
CreatedAt time.Time
|
|
||||||
|
|
||||||
// DryRun indicates if this is a dry-run plan
|
|
||||||
DryRun bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppAction represents an action to be performed on an application
|
|
||||||
type AppAction struct {
|
|
||||||
// Type of action to perform
|
|
||||||
Type ActionType
|
|
||||||
|
|
||||||
// Current state of the app (nil if doesn't exist)
|
|
||||||
Current *AppState
|
|
||||||
|
|
||||||
// Desired state of the app
|
|
||||||
Desired *AppState
|
|
||||||
|
|
||||||
// Changes describes what will change
|
|
||||||
Changes []string
|
|
||||||
|
|
||||||
// Reason explains why this action is needed
|
|
||||||
Reason string
|
|
||||||
|
|
||||||
// ManifestHash is the hash of the current manifest file
|
|
||||||
ManifestHash string
|
|
||||||
|
|
||||||
// ManifestChanged indicates if the manifest content has changed
|
|
||||||
ManifestChanged bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstanceAction represents an action to be performed on an application instance
|
|
||||||
type InstanceAction struct {
|
|
||||||
// Type of action to perform
|
|
||||||
Type ActionType
|
|
||||||
|
|
||||||
// Target infrastructure where the instance will be deployed
|
|
||||||
Target config.InfraTemplate
|
|
||||||
|
|
||||||
// Current state of the instance (nil if doesn't exist)
|
|
||||||
Current *InstanceState
|
|
||||||
|
|
||||||
// Desired state of the instance
|
|
||||||
Desired *InstanceState
|
|
||||||
|
|
||||||
// Changes describes what will change
|
|
||||||
Changes []string
|
|
||||||
|
|
||||||
// Reason explains why this action is needed
|
|
||||||
Reason string
|
|
||||||
|
|
||||||
// InstanceName is the generated name for this instance
|
|
||||||
InstanceName string
|
|
||||||
|
|
||||||
// Dependencies lists other instances this depends on
|
|
||||||
Dependencies []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppState represents the current state of an application
|
|
||||||
type AppState struct {
|
|
||||||
// Name of the application
|
|
||||||
Name string
|
|
||||||
|
|
||||||
// Version of the application
|
|
||||||
Version string
|
|
||||||
|
|
||||||
// Organization that owns the app
|
|
||||||
Organization string
|
|
||||||
|
|
||||||
// Region where the app is deployed
|
|
||||||
Region string
|
|
||||||
|
|
||||||
// ManifestHash is the stored hash of the manifest file
|
|
||||||
ManifestHash string
|
|
||||||
|
|
||||||
// LastUpdated timestamp when the app was last modified
|
|
||||||
LastUpdated time.Time
|
|
||||||
|
|
||||||
// Exists indicates if the app currently exists
|
|
||||||
Exists bool
|
|
||||||
|
|
||||||
// AppType indicates whether this is a k8s or docker app
|
|
||||||
AppType AppType
|
|
||||||
|
|
||||||
// OutboundConnections contains the required outbound network connections
|
|
||||||
OutboundConnections []SecurityRule
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstanceState represents the current state of an application instance
|
|
||||||
type InstanceState struct {
|
|
||||||
// Name of the instance
|
|
||||||
Name string
|
|
||||||
|
|
||||||
// AppName that this instance belongs to
|
|
||||||
AppName string
|
|
||||||
|
|
||||||
// AppVersion of the associated app
|
|
||||||
AppVersion string
|
|
||||||
|
|
||||||
// Organization that owns the instance
|
|
||||||
Organization string
|
|
||||||
|
|
||||||
// Region where the instance is deployed
|
|
||||||
Region string
|
|
||||||
|
|
||||||
// CloudletOrg that hosts the cloudlet
|
|
||||||
CloudletOrg string
|
|
||||||
|
|
||||||
// CloudletName where the instance is running
|
|
||||||
CloudletName string
|
|
||||||
|
|
||||||
// FlavorName used for the instance
|
|
||||||
FlavorName string
|
|
||||||
|
|
||||||
// State of the instance (e.g., "Ready", "Pending", "Error")
|
|
||||||
State string
|
|
||||||
|
|
||||||
// PowerState of the instance
|
|
||||||
PowerState string
|
|
||||||
|
|
||||||
// LastUpdated timestamp when the instance was last modified
|
|
||||||
LastUpdated time.Time
|
|
||||||
|
|
||||||
// Exists indicates if the instance currently exists
|
|
||||||
Exists bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppType represents the type of application
|
|
||||||
type AppType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// AppTypeK8s represents a Kubernetes application
|
|
||||||
AppTypeK8s AppType = "k8s"
|
|
||||||
// AppTypeDocker represents a Docker application
|
|
||||||
AppTypeDocker AppType = "docker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// String returns the string representation of AppType
|
|
||||||
func (a AppType) String() string {
|
|
||||||
return string(a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeploymentSummary provides a high-level overview of the deployment plan
|
|
||||||
type DeploymentSummary struct {
|
|
||||||
// TotalActions is the total number of actions to be performed
|
|
||||||
TotalActions int
|
|
||||||
|
|
||||||
// ActionCounts breaks down actions by type
|
|
||||||
ActionCounts map[ActionType]int
|
|
||||||
|
|
||||||
// EstimatedDuration for the entire deployment
|
|
||||||
EstimatedDuration time.Duration
|
|
||||||
|
|
||||||
// ResourceSummary describes the resources involved
|
|
||||||
ResourceSummary ResourceSummary
|
|
||||||
|
|
||||||
// Warnings about potential issues
|
|
||||||
Warnings []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResourceSummary provides details about resources in the deployment
|
|
||||||
type ResourceSummary struct {
|
|
||||||
// AppsToCreate number of apps that will be created
|
|
||||||
AppsToCreate int
|
|
||||||
|
|
||||||
// AppsToUpdate number of apps that will be updated
|
|
||||||
AppsToUpdate int
|
|
||||||
|
|
||||||
// InstancesToCreate number of instances that will be created
|
|
||||||
InstancesToCreate int
|
|
||||||
|
|
||||||
// InstancesToUpdate number of instances that will be updated
|
|
||||||
InstancesToUpdate int
|
|
||||||
|
|
||||||
// CloudletsAffected number of unique cloudlets involved
|
|
||||||
CloudletsAffected int
|
|
||||||
|
|
||||||
// RegionsAffected number of unique regions involved
|
|
||||||
RegionsAffected int
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanResult represents the result of a deployment planning operation
|
|
||||||
type PlanResult struct {
|
|
||||||
// Plan is the generated deployment plan
|
|
||||||
Plan *DeploymentPlan
|
|
||||||
|
|
||||||
// Error if planning failed
|
|
||||||
Error error
|
|
||||||
|
|
||||||
// Warnings encountered during planning
|
|
||||||
Warnings []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecutionResult represents the result of executing a deployment plan
|
|
||||||
type ExecutionResult struct {
|
|
||||||
// Plan that was executed
|
|
||||||
Plan *DeploymentPlan
|
|
||||||
|
|
||||||
// Success indicates if the deployment was successful
|
|
||||||
Success bool
|
|
||||||
|
|
||||||
// CompletedActions lists actions that were successfully completed
|
|
||||||
CompletedActions []ActionResult
|
|
||||||
|
|
||||||
// FailedActions lists actions that failed
|
|
||||||
FailedActions []ActionResult
|
|
||||||
|
|
||||||
// Error that caused the deployment to fail (if any)
|
|
||||||
Error error
|
|
||||||
|
|
||||||
// Duration taken to execute the plan
|
|
||||||
Duration time.Duration
|
|
||||||
|
|
||||||
// RollbackPerformed indicates if rollback was executed
|
|
||||||
RollbackPerformed bool
|
|
||||||
|
|
||||||
// RollbackSuccess indicates if rollback was successful
|
|
||||||
RollbackSuccess bool
|
|
||||||
|
|
||||||
// DeletedAppBackup stores the app that was deleted (for rollback restoration)
|
|
||||||
DeletedAppBackup *AppBackup
|
|
||||||
|
|
||||||
// DeletedInstancesBackup stores instances that were deleted (for rollback restoration)
|
|
||||||
DeletedInstancesBackup []InstanceBackup
|
|
||||||
}
|
|
||||||
|
|
||||||
// ActionResult represents the result of executing a single action
|
|
||||||
type ActionResult struct {
|
|
||||||
// Type of action that was attempted
|
|
||||||
Type ActionType
|
|
||||||
|
|
||||||
// Target describes what was being acted upon
|
|
||||||
Target string
|
|
||||||
|
|
||||||
// Success indicates if the action succeeded
|
|
||||||
Success bool
|
|
||||||
|
|
||||||
// Error if the action failed
|
|
||||||
Error error
|
|
||||||
|
|
||||||
// Duration taken to complete the action
|
|
||||||
Duration time.Duration
|
|
||||||
|
|
||||||
// Details provides additional information about the action
|
|
||||||
Details string
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppBackup stores a deleted app's complete state for rollback restoration
|
|
||||||
type AppBackup struct {
|
|
||||||
// App is the full app object that was deleted
|
|
||||||
App v2.App
|
|
||||||
|
|
||||||
// Region where the app was deployed
|
|
||||||
Region string
|
|
||||||
|
|
||||||
// ManifestContent is the deployment manifest content
|
|
||||||
ManifestContent string
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstanceBackup stores a deleted instance's complete state for rollback restoration
|
|
||||||
type InstanceBackup struct {
|
|
||||||
// Instance is the full instance object that was deleted
|
|
||||||
Instance v2.AppInstance
|
|
||||||
|
|
||||||
// Region where the instance was deployed
|
|
||||||
Region string
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty returns true if the deployment plan has no actions to perform
|
|
||||||
func (dp *DeploymentPlan) IsEmpty() bool {
|
|
||||||
if dp.AppAction.Type != ActionNone {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, action := range dp.InstanceActions {
|
|
||||||
if action.Type != ActionNone {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasErrors returns true if the plan contains any error conditions
|
|
||||||
func (dp *DeploymentPlan) HasErrors() bool {
|
|
||||||
// Check for conflicting actions or invalid states
|
|
||||||
return false // Implementation would check for various error conditions
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTargetCloudlets returns a list of unique cloudlets that will be affected
|
|
||||||
func (dp *DeploymentPlan) GetTargetCloudlets() []string {
|
|
||||||
cloudletSet := make(map[string]bool)
|
|
||||||
var cloudlets []string
|
|
||||||
|
|
||||||
for _, action := range dp.InstanceActions {
|
|
||||||
if action.Type != ActionNone {
|
|
||||||
key := fmt.Sprintf("%s:%s", action.Target.CloudletOrg, action.Target.CloudletName)
|
|
||||||
if !cloudletSet[key] {
|
|
||||||
cloudletSet[key] = true
|
|
||||||
cloudlets = append(cloudlets, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cloudlets
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTargetRegions returns a list of unique regions that will be affected
|
|
||||||
func (dp *DeploymentPlan) GetTargetRegions() []string {
|
|
||||||
regionSet := make(map[string]bool)
|
|
||||||
var regions []string
|
|
||||||
|
|
||||||
for _, action := range dp.InstanceActions {
|
|
||||||
if action.Type != ActionNone && !regionSet[action.Target.Region] {
|
|
||||||
regionSet[action.Target.Region] = true
|
|
||||||
regions = append(regions, action.Target.Region)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return regions
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateSummary creates a human-readable summary of the deployment plan
|
|
||||||
func (dp *DeploymentPlan) GenerateSummary() string {
|
|
||||||
if dp.IsEmpty() {
|
|
||||||
return "No changes required - configuration matches current state"
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName))
|
|
||||||
|
|
||||||
// App actions
|
|
||||||
if dp.AppAction.Type != ActionNone {
|
|
||||||
sb.WriteString(fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name))
|
|
||||||
if len(dp.AppAction.Changes) > 0 {
|
|
||||||
for _, change := range dp.AppAction.Changes {
|
|
||||||
sb.WriteString(fmt.Sprintf(" - %s\n", change))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instance actions
|
|
||||||
createCount := 0
|
|
||||||
updateActions := []InstanceAction{}
|
|
||||||
for _, action := range dp.InstanceActions {
|
|
||||||
switch action.Type {
|
|
||||||
case ActionCreate:
|
|
||||||
createCount++
|
|
||||||
case ActionUpdate:
|
|
||||||
updateActions = append(updateActions, action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if createCount > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets())))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(updateActions) > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf("- UPDATE %d instance(s)\n", len(updateActions)))
|
|
||||||
for _, action := range updateActions {
|
|
||||||
if len(action.Changes) > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf(" - Instance '%s':\n", action.InstanceName))
|
|
||||||
for _, change := range action.Changes {
|
|
||||||
sb.WriteString(fmt.Sprintf(" - %s\n", change))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate checks if the deployment plan is valid and safe to execute
|
|
||||||
func (dp *DeploymentPlan) Validate() error {
|
|
||||||
if dp.ConfigName == "" {
|
|
||||||
return fmt.Errorf("deployment plan must have a config name")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate app action
|
|
||||||
if dp.AppAction.Type != ActionNone && dp.AppAction.Desired == nil {
|
|
||||||
return fmt.Errorf("app action of type %s must have desired state", dp.AppAction.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate instance actions
|
|
||||||
for i, action := range dp.InstanceActions {
|
|
||||||
if action.Type != ActionNone {
|
|
||||||
if action.Desired == nil {
|
|
||||||
return fmt.Errorf("instance action %d of type %s must have desired state", i, action.Type)
|
|
||||||
}
|
|
||||||
if action.InstanceName == "" {
|
|
||||||
return fmt.Errorf("instance action %d must have an instance name", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone creates a deep copy of the deployment plan
|
|
||||||
func (dp *DeploymentPlan) Clone() *DeploymentPlan {
|
|
||||||
clone := &DeploymentPlan{
|
|
||||||
ConfigName: dp.ConfigName,
|
|
||||||
Summary: dp.Summary,
|
|
||||||
TotalActions: dp.TotalActions,
|
|
||||||
EstimatedDuration: dp.EstimatedDuration,
|
|
||||||
CreatedAt: dp.CreatedAt,
|
|
||||||
DryRun: dp.DryRun,
|
|
||||||
AppAction: dp.AppAction, // Struct copy is sufficient for this use case
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep copy instance actions
|
|
||||||
clone.InstanceActions = make([]InstanceAction, len(dp.InstanceActions))
|
|
||||||
copy(clone.InstanceActions, dp.InstanceActions)
|
|
||||||
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
// convertNetworkRules converts config network rules to EdgeConnect SecurityRules
|
|
||||||
func convertNetworkRules(network *config.NetworkConfig) []v2.SecurityRule {
|
|
||||||
rules := make([]v2.SecurityRule, len(network.OutboundConnections))
|
|
||||||
|
|
||||||
for i, conn := range network.OutboundConnections {
|
|
||||||
rules[i] = v2.SecurityRule{
|
|
||||||
Protocol: conn.Protocol,
|
|
||||||
PortRangeMin: conn.PortRangeMin,
|
|
||||||
PortRangeMax: conn.PortRangeMax,
|
|
||||||
RemoteCIDR: conn.RemoteCIDR,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rules
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
// ABOUTME: Resource management for EdgeConnect delete command with deletion execution
|
|
||||||
// ABOUTME: Handles actual deletion operations with proper ordering (instances first, then app)
|
|
||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ResourceManagerInterface defines the interface for resource management
|
|
||||||
type ResourceManagerInterface interface {
|
|
||||||
// ExecuteDeletion executes a deletion plan
|
|
||||||
ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EdgeConnectResourceManager implements resource management for EdgeConnect
|
|
||||||
type EdgeConnectResourceManager struct {
|
|
||||||
client EdgeConnectClientInterface
|
|
||||||
logger Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logger interface for deletion logging
|
|
||||||
type Logger interface {
|
|
||||||
Printf(format string, v ...interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResourceManagerOptions configures the resource manager behavior
|
|
||||||
type ResourceManagerOptions struct {
|
|
||||||
// Logger for deletion operations
|
|
||||||
Logger Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultResourceManagerOptions returns sensible defaults
|
|
||||||
func DefaultResourceManagerOptions() ResourceManagerOptions {
|
|
||||||
return ResourceManagerOptions{
|
|
||||||
Logger: nil,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewResourceManager creates a new EdgeConnect resource manager
|
|
||||||
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
|
||||||
options := DefaultResourceManagerOptions()
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&options)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &EdgeConnectResourceManager{
|
|
||||||
client: client,
|
|
||||||
logger: options.Logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithLogger sets a logger for deletion operations
|
|
||||||
func WithLogger(logger Logger) func(*ResourceManagerOptions) {
|
|
||||||
return func(opts *ResourceManagerOptions) {
|
|
||||||
opts.Logger = logger
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteDeletion executes a deletion plan
|
|
||||||
// Important: Instances must be deleted before the app
|
|
||||||
func (rm *EdgeConnectResourceManager) ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error) {
|
|
||||||
startTime := time.Now()
|
|
||||||
rm.logf("Starting deletion: %s", plan.ConfigName)
|
|
||||||
|
|
||||||
result := &DeletionResult{
|
|
||||||
Plan: plan,
|
|
||||||
Success: true,
|
|
||||||
CompletedActions: []DeletionActionResult{},
|
|
||||||
FailedActions: []DeletionActionResult{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// If plan is empty, return success immediately
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
rm.logf("No resources to delete")
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Delete all instances first
|
|
||||||
for _, instance := range plan.InstancesToDelete {
|
|
||||||
actionStart := time.Now()
|
|
||||||
rm.logf("Deleting instance: %s", instance.Name)
|
|
||||||
|
|
||||||
instanceKey := edgeconnect.AppInstanceKey{
|
|
||||||
Organization: instance.Organization,
|
|
||||||
Name: instance.Name,
|
|
||||||
CloudletKey: edgeconnect.CloudletKey{
|
|
||||||
Organization: instance.CloudletOrg,
|
|
||||||
Name: instance.CloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := rm.client.DeleteAppInstance(ctx, instanceKey, instance.Region)
|
|
||||||
actionResult := DeletionActionResult{
|
|
||||||
Type: "instance",
|
|
||||||
Target: instance.Name,
|
|
||||||
Duration: time.Since(actionStart),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
rm.logf("Failed to delete instance %s: %v", instance.Name, err)
|
|
||||||
actionResult.Success = false
|
|
||||||
actionResult.Error = err
|
|
||||||
result.FailedActions = append(result.FailedActions, actionResult)
|
|
||||||
result.Success = false
|
|
||||||
result.Error = fmt.Errorf("failed to delete instance %s: %w", instance.Name, err)
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
rm.logf("Successfully deleted instance: %s", instance.Name)
|
|
||||||
actionResult.Success = true
|
|
||||||
result.CompletedActions = append(result.CompletedActions, actionResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Delete the app (only after all instances are deleted)
|
|
||||||
if plan.AppToDelete != nil {
|
|
||||||
actionStart := time.Now()
|
|
||||||
app := plan.AppToDelete
|
|
||||||
rm.logf("Deleting app: %s version %s", app.Name, app.Version)
|
|
||||||
|
|
||||||
appKey := edgeconnect.AppKey{
|
|
||||||
Organization: app.Organization,
|
|
||||||
Name: app.Name,
|
|
||||||
Version: app.Version,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := rm.client.DeleteApp(ctx, appKey, app.Region)
|
|
||||||
actionResult := DeletionActionResult{
|
|
||||||
Type: "app",
|
|
||||||
Target: fmt.Sprintf("%s:%s", app.Name, app.Version),
|
|
||||||
Duration: time.Since(actionStart),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
rm.logf("Failed to delete app %s: %v", app.Name, err)
|
|
||||||
actionResult.Success = false
|
|
||||||
actionResult.Error = err
|
|
||||||
result.FailedActions = append(result.FailedActions, actionResult)
|
|
||||||
result.Success = false
|
|
||||||
result.Error = fmt.Errorf("failed to delete app %s: %w", app.Name, err)
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
rm.logf("Successfully deleted app: %s", app.Name)
|
|
||||||
actionResult.Success = true
|
|
||||||
result.CompletedActions = append(result.CompletedActions, actionResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
rm.logf("Deletion completed successfully in %v", result.Duration)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// logf logs a message if a logger is configured
|
|
||||||
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
|
|
||||||
if rm.logger != nil {
|
|
||||||
rm.logger.Printf(format, v...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
// ABOUTME: Deletion planner for EdgeConnect delete command
|
|
||||||
// ABOUTME: Analyzes current state to identify resources for deletion
|
|
||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EdgeConnectClientInterface defines the methods needed for deletion planning
|
|
||||||
type EdgeConnectClientInterface interface {
|
|
||||||
ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error)
|
|
||||||
ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) ([]edgeconnect.AppInstance, error)
|
|
||||||
DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error
|
|
||||||
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Planner defines the interface for deletion planning
|
|
||||||
type Planner interface {
|
|
||||||
// Plan analyzes the configuration and current state to generate a deletion plan
|
|
||||||
Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error)
|
|
||||||
|
|
||||||
// PlanWithOptions allows customization of planning behavior
|
|
||||||
PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanOptions provides configuration for the planning process
|
|
||||||
type PlanOptions struct {
|
|
||||||
// DryRun indicates this is a planning-only operation
|
|
||||||
DryRun bool
|
|
||||||
|
|
||||||
// Timeout for API operations
|
|
||||||
Timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultPlanOptions returns sensible default planning options
|
|
||||||
func DefaultPlanOptions() PlanOptions {
|
|
||||||
return PlanOptions{
|
|
||||||
DryRun: false,
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
|
|
||||||
type EdgeConnectPlanner struct {
|
|
||||||
client EdgeConnectClientInterface
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPlanner creates a new EdgeConnect deletion planner
|
|
||||||
func NewPlanner(client EdgeConnectClientInterface) Planner {
|
|
||||||
return &EdgeConnectPlanner{
|
|
||||||
client: client,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan analyzes the configuration and generates a deletion plan
|
|
||||||
func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) {
|
|
||||||
return p.PlanWithOptions(ctx, config, DefaultPlanOptions())
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanWithOptions generates a deletion plan with custom options
|
|
||||||
func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) {
|
|
||||||
startTime := time.Now()
|
|
||||||
var warnings []string
|
|
||||||
|
|
||||||
// Create the deletion plan structure
|
|
||||||
plan := &DeletionPlan{
|
|
||||||
ConfigName: config.Metadata.Name,
|
|
||||||
CreatedAt: startTime,
|
|
||||||
DryRun: opts.DryRun,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the region from the first infra template
|
|
||||||
region := config.Spec.InfraTemplate[0].Region
|
|
||||||
|
|
||||||
// Step 1: Check if instances exist
|
|
||||||
instancesResult := p.findInstancesToDelete(ctx, config, region)
|
|
||||||
plan.InstancesToDelete = instancesResult.instances
|
|
||||||
if instancesResult.err != nil {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("Error querying instances: %v", instancesResult.err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Check if app exists
|
|
||||||
appResult := p.findAppToDelete(ctx, config, region)
|
|
||||||
plan.AppToDelete = appResult.app
|
|
||||||
if appResult.err != nil && !isNotFoundError(appResult.err) {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("Error querying app: %v", appResult.err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Calculate plan metadata
|
|
||||||
p.calculatePlanMetadata(plan)
|
|
||||||
|
|
||||||
// Step 4: Generate summary
|
|
||||||
plan.Summary = plan.GenerateSummary()
|
|
||||||
|
|
||||||
return &PlanResult{
|
|
||||||
Plan: plan,
|
|
||||||
Warnings: warnings,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type appQueryResult struct {
|
|
||||||
app *AppDeletion
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type instancesQueryResult struct {
|
|
||||||
instances []InstanceDeletion
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// findAppToDelete checks if the app exists and should be deleted
|
|
||||||
func (p *EdgeConnectPlanner) findAppToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) appQueryResult {
|
|
||||||
appKey := edgeconnect.AppKey{
|
|
||||||
Organization: config.Metadata.Organization,
|
|
||||||
Name: config.Metadata.Name,
|
|
||||||
Version: config.Metadata.AppVersion,
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := p.client.ShowApp(ctx, appKey, region)
|
|
||||||
if err != nil {
|
|
||||||
if isNotFoundError(err) {
|
|
||||||
return appQueryResult{app: nil, err: nil}
|
|
||||||
}
|
|
||||||
return appQueryResult{app: nil, err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return appQueryResult{
|
|
||||||
app: &AppDeletion{
|
|
||||||
Name: app.Key.Name,
|
|
||||||
Version: app.Key.Version,
|
|
||||||
Organization: app.Key.Organization,
|
|
||||||
Region: region,
|
|
||||||
},
|
|
||||||
err: nil,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// findInstancesToDelete finds all instances that match the config
|
|
||||||
func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) instancesQueryResult {
|
|
||||||
var allInstances []InstanceDeletion
|
|
||||||
|
|
||||||
// Query instances for each infra template
|
|
||||||
for _, infra := range config.Spec.InfraTemplate {
|
|
||||||
instanceKey := edgeconnect.AppInstanceKey{
|
|
||||||
Organization: config.Metadata.Organization,
|
|
||||||
Name: generateInstanceName(config.Metadata.Name, config.Metadata.AppVersion),
|
|
||||||
CloudletKey: edgeconnect.CloudletKey{
|
|
||||||
Organization: infra.CloudletOrg,
|
|
||||||
Name: infra.CloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
appKey := edgeconnect.AppKey{Name: config.Metadata.Name}
|
|
||||||
|
|
||||||
instances, err := p.client.ShowAppInstances(ctx, instanceKey, appKey, infra.Region)
|
|
||||||
if err != nil {
|
|
||||||
// If it's a not found error, just continue
|
|
||||||
if isNotFoundError(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return instancesQueryResult{instances: nil, err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add found instances to the list
|
|
||||||
for _, inst := range instances {
|
|
||||||
allInstances = append(allInstances, InstanceDeletion{
|
|
||||||
Name: inst.Key.Name,
|
|
||||||
Organization: inst.Key.Organization,
|
|
||||||
Region: infra.Region,
|
|
||||||
CloudletOrg: inst.Key.CloudletKey.Organization,
|
|
||||||
CloudletName: inst.Key.CloudletKey.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return instancesQueryResult{
|
|
||||||
instances: allInstances,
|
|
||||||
err: nil,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculatePlanMetadata calculates the total actions and estimated duration
|
|
||||||
func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeletionPlan) {
|
|
||||||
totalActions := 0
|
|
||||||
|
|
||||||
if plan.AppToDelete != nil {
|
|
||||||
totalActions++
|
|
||||||
}
|
|
||||||
|
|
||||||
totalActions += len(plan.InstancesToDelete)
|
|
||||||
|
|
||||||
plan.TotalActions = totalActions
|
|
||||||
|
|
||||||
// Estimate duration: ~5 seconds per instance, ~3 seconds for app
|
|
||||||
estimatedSeconds := len(plan.InstancesToDelete) * 5
|
|
||||||
if plan.AppToDelete != nil {
|
|
||||||
estimatedSeconds += 3
|
|
||||||
}
|
|
||||||
plan.EstimatedDuration = time.Duration(estimatedSeconds) * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateInstanceName creates an instance name from app name and version
|
|
||||||
func generateInstanceName(appName, appVersion string) string {
|
|
||||||
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isNotFoundError checks if an error is a 404 not found error
|
|
||||||
func isNotFoundError(err error) bool {
|
|
||||||
if apiErr, ok := err.(*edgeconnect.APIError); ok {
|
|
||||||
return apiErr.StatusCode == 404
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanResult represents the result of a deletion planning operation
|
|
||||||
type PlanResult struct {
|
|
||||||
// Plan is the generated deletion plan
|
|
||||||
Plan *DeletionPlan
|
|
||||||
|
|
||||||
// Error if planning failed
|
|
||||||
Error error
|
|
||||||
|
|
||||||
// Warnings encountered during planning
|
|
||||||
Warnings []string
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
// ABOUTME: Deletion planning types for EdgeConnect delete command
|
|
||||||
// ABOUTME: Defines structures for deletion plans and deletion results
|
|
||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DeletionPlan represents the complete deletion plan for a configuration
|
|
||||||
type DeletionPlan struct {
|
|
||||||
// ConfigName is the name from metadata
|
|
||||||
ConfigName string
|
|
||||||
|
|
||||||
// AppToDelete defines the app that will be deleted (nil if app doesn't exist)
|
|
||||||
AppToDelete *AppDeletion
|
|
||||||
|
|
||||||
// InstancesToDelete defines the instances that will be deleted
|
|
||||||
InstancesToDelete []InstanceDeletion
|
|
||||||
|
|
||||||
// Summary provides a human-readable summary of the plan
|
|
||||||
Summary string
|
|
||||||
|
|
||||||
// TotalActions is the count of all actions that will be performed
|
|
||||||
TotalActions int
|
|
||||||
|
|
||||||
// EstimatedDuration is the estimated time to complete the deletion
|
|
||||||
EstimatedDuration time.Duration
|
|
||||||
|
|
||||||
// CreatedAt timestamp when the plan was created
|
|
||||||
CreatedAt time.Time
|
|
||||||
|
|
||||||
// DryRun indicates if this is a dry-run plan
|
|
||||||
DryRun bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppDeletion represents an application to be deleted
|
|
||||||
type AppDeletion struct {
|
|
||||||
// Name of the application
|
|
||||||
Name string
|
|
||||||
|
|
||||||
// Version of the application
|
|
||||||
Version string
|
|
||||||
|
|
||||||
// Organization that owns the app
|
|
||||||
Organization string
|
|
||||||
|
|
||||||
// Region where the app is deployed
|
|
||||||
Region string
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstanceDeletion represents an application instance to be deleted
|
|
||||||
type InstanceDeletion struct {
|
|
||||||
// Name of the instance
|
|
||||||
Name string
|
|
||||||
|
|
||||||
// Organization that owns the instance
|
|
||||||
Organization string
|
|
||||||
|
|
||||||
// Region where the instance is deployed
|
|
||||||
Region string
|
|
||||||
|
|
||||||
// CloudletOrg that hosts the cloudlet
|
|
||||||
CloudletOrg string
|
|
||||||
|
|
||||||
// CloudletName where the instance is running
|
|
||||||
CloudletName string
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeletionResult represents the result of a deletion operation
|
|
||||||
type DeletionResult struct {
|
|
||||||
// Plan that was executed
|
|
||||||
Plan *DeletionPlan
|
|
||||||
|
|
||||||
// Success indicates if the deletion was successful
|
|
||||||
Success bool
|
|
||||||
|
|
||||||
// CompletedActions lists actions that were successfully completed
|
|
||||||
CompletedActions []DeletionActionResult
|
|
||||||
|
|
||||||
// FailedActions lists actions that failed
|
|
||||||
FailedActions []DeletionActionResult
|
|
||||||
|
|
||||||
// Error that caused the deletion to fail (if any)
|
|
||||||
Error error
|
|
||||||
|
|
||||||
// Duration taken to execute the plan
|
|
||||||
Duration time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeletionActionResult represents the result of executing a single deletion action
|
|
||||||
type DeletionActionResult struct {
|
|
||||||
// Type of resource that was deleted ("app" or "instance")
|
|
||||||
Type string
|
|
||||||
|
|
||||||
// Target describes what was being deleted
|
|
||||||
Target string
|
|
||||||
|
|
||||||
// Success indicates if the action succeeded
|
|
||||||
Success bool
|
|
||||||
|
|
||||||
// Error if the action failed
|
|
||||||
Error error
|
|
||||||
|
|
||||||
// Duration taken to complete the action
|
|
||||||
Duration time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty returns true if the deletion plan has no actions to perform
|
|
||||||
func (dp *DeletionPlan) IsEmpty() bool {
|
|
||||||
return dp.AppToDelete == nil && len(dp.InstancesToDelete) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateSummary creates a human-readable summary of the deletion plan
|
|
||||||
func (dp *DeletionPlan) GenerateSummary() string {
|
|
||||||
if dp.IsEmpty() {
|
|
||||||
return "No resources found to delete"
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("Deletion plan for '%s':\n", dp.ConfigName))
|
|
||||||
|
|
||||||
// Instance actions
|
|
||||||
if len(dp.InstancesToDelete) > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf("- DELETE %d instance(s)\n", len(dp.InstancesToDelete)))
|
|
||||||
cloudletSet := make(map[string]bool)
|
|
||||||
for _, inst := range dp.InstancesToDelete {
|
|
||||||
key := fmt.Sprintf("%s:%s", inst.CloudletOrg, inst.CloudletName)
|
|
||||||
cloudletSet[key] = true
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf(" Across %d cloudlet(s)\n", len(cloudletSet)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// App action
|
|
||||||
if dp.AppToDelete != nil {
|
|
||||||
sb.WriteString(fmt.Sprintf("- DELETE application '%s' version %s\n",
|
|
||||||
dp.AppToDelete.Name, dp.AppToDelete.Version))
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate checks if the deletion plan is valid
|
|
||||||
func (dp *DeletionPlan) Validate() error {
|
|
||||||
if dp.ConfigName == "" {
|
|
||||||
return fmt.Errorf("deletion plan must have a config name")
|
|
||||||
}
|
|
||||||
|
|
||||||
if dp.IsEmpty() {
|
|
||||||
return fmt.Errorf("deletion plan has no resources to delete")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
// ABOUTME: Resource management for EdgeConnect delete command with deletion execution
|
|
||||||
// ABOUTME: Handles actual deletion operations with proper ordering (instances first, then app)
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ResourceManagerInterface defines the interface for resource management
|
|
||||||
type ResourceManagerInterface interface {
|
|
||||||
// ExecuteDeletion executes a deletion plan
|
|
||||||
ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EdgeConnectResourceManager implements resource management for EdgeConnect
|
|
||||||
type EdgeConnectResourceManager struct {
|
|
||||||
client EdgeConnectClientInterface
|
|
||||||
logger Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logger interface for deletion logging
|
|
||||||
type Logger interface {
|
|
||||||
Printf(format string, v ...interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResourceManagerOptions configures the resource manager behavior
|
|
||||||
type ResourceManagerOptions struct {
|
|
||||||
// Logger for deletion operations
|
|
||||||
Logger Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultResourceManagerOptions returns sensible defaults
|
|
||||||
func DefaultResourceManagerOptions() ResourceManagerOptions {
|
|
||||||
return ResourceManagerOptions{
|
|
||||||
Logger: nil,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewResourceManager creates a new EdgeConnect resource manager
|
|
||||||
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
|
||||||
options := DefaultResourceManagerOptions()
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&options)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &EdgeConnectResourceManager{
|
|
||||||
client: client,
|
|
||||||
logger: options.Logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithLogger sets a logger for deletion operations
|
|
||||||
func WithLogger(logger Logger) func(*ResourceManagerOptions) {
|
|
||||||
return func(opts *ResourceManagerOptions) {
|
|
||||||
opts.Logger = logger
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteDeletion executes a deletion plan
|
|
||||||
// Important: Instances must be deleted before the app
|
|
||||||
func (rm *EdgeConnectResourceManager) ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error) {
|
|
||||||
startTime := time.Now()
|
|
||||||
rm.logf("Starting deletion: %s", plan.ConfigName)
|
|
||||||
|
|
||||||
result := &DeletionResult{
|
|
||||||
Plan: plan,
|
|
||||||
Success: true,
|
|
||||||
CompletedActions: []DeletionActionResult{},
|
|
||||||
FailedActions: []DeletionActionResult{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// If plan is empty, return success immediately
|
|
||||||
if plan.IsEmpty() {
|
|
||||||
rm.logf("No resources to delete")
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Delete all instances first
|
|
||||||
for _, instance := range plan.InstancesToDelete {
|
|
||||||
actionStart := time.Now()
|
|
||||||
rm.logf("Deleting instance: %s", instance.Name)
|
|
||||||
|
|
||||||
instanceKey := v2.AppInstanceKey{
|
|
||||||
Organization: instance.Organization,
|
|
||||||
Name: instance.Name,
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: instance.CloudletOrg,
|
|
||||||
Name: instance.CloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := rm.client.DeleteAppInstance(ctx, instanceKey, instance.Region)
|
|
||||||
actionResult := DeletionActionResult{
|
|
||||||
Type: "instance",
|
|
||||||
Target: instance.Name,
|
|
||||||
Duration: time.Since(actionStart),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
rm.logf("Failed to delete instance %s: %v", instance.Name, err)
|
|
||||||
actionResult.Success = false
|
|
||||||
actionResult.Error = err
|
|
||||||
result.FailedActions = append(result.FailedActions, actionResult)
|
|
||||||
result.Success = false
|
|
||||||
result.Error = fmt.Errorf("failed to delete instance %s: %w", instance.Name, err)
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
rm.logf("Successfully deleted instance: %s", instance.Name)
|
|
||||||
actionResult.Success = true
|
|
||||||
result.CompletedActions = append(result.CompletedActions, actionResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Delete the app (only after all instances are deleted)
|
|
||||||
if plan.AppToDelete != nil {
|
|
||||||
actionStart := time.Now()
|
|
||||||
app := plan.AppToDelete
|
|
||||||
rm.logf("Deleting app: %s version %s", app.Name, app.Version)
|
|
||||||
|
|
||||||
appKey := v2.AppKey{
|
|
||||||
Organization: app.Organization,
|
|
||||||
Name: app.Name,
|
|
||||||
Version: app.Version,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := rm.client.DeleteApp(ctx, appKey, app.Region)
|
|
||||||
actionResult := DeletionActionResult{
|
|
||||||
Type: "app",
|
|
||||||
Target: fmt.Sprintf("%s:%s", app.Name, app.Version),
|
|
||||||
Duration: time.Since(actionStart),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
rm.logf("Failed to delete app %s: %v", app.Name, err)
|
|
||||||
actionResult.Success = false
|
|
||||||
actionResult.Error = err
|
|
||||||
result.FailedActions = append(result.FailedActions, actionResult)
|
|
||||||
result.Success = false
|
|
||||||
result.Error = fmt.Errorf("failed to delete app %s: %w", app.Name, err)
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
return result, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
rm.logf("Successfully deleted app: %s", app.Name)
|
|
||||||
actionResult.Success = true
|
|
||||||
result.CompletedActions = append(result.CompletedActions, actionResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Duration = time.Since(startTime)
|
|
||||||
rm.logf("Deletion completed successfully in %v", result.Duration)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// logf logs a message if a logger is configured
|
|
||||||
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
|
|
||||||
if rm.logger != nil {
|
|
||||||
rm.logger.Printf(format, v...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
// ABOUTME: Tests for EdgeConnect deletion manager with mock scenarios
|
|
||||||
// ABOUTME: Tests deletion execution and error handling with mock clients
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockResourceClient for testing deletion manager
|
|
||||||
type MockResourceClient struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockResourceClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) {
|
|
||||||
args := m.Called(ctx, appKey, region)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return v2.App{}, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(v2.App), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockResourceClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) {
|
|
||||||
args := m.Called(ctx, instanceKey, region)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).([]v2.AppInstance), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
|
|
||||||
args := m.Called(ctx, appKey, region)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
|
|
||||||
args := m.Called(ctx, instanceKey, region)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLogger implements Logger interface for testing
|
|
||||||
type TestLogger struct {
|
|
||||||
messages []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *TestLogger) Printf(format string, v ...interface{}) {
|
|
||||||
l.messages = append(l.messages, fmt.Sprintf(format, v...))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewResourceManager(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
manager := NewResourceManager(mockClient)
|
|
||||||
|
|
||||||
assert.NotNil(t, manager)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithLogger(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
logger := &TestLogger{}
|
|
||||||
|
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger))
|
|
||||||
|
|
||||||
// Cast to implementation to check logger was set
|
|
||||||
impl := manager.(*EdgeConnectResourceManager)
|
|
||||||
assert.Equal(t, logger, impl.logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTestDeletionPlan() *DeletionPlan {
|
|
||||||
return &DeletionPlan{
|
|
||||||
ConfigName: "test-deletion",
|
|
||||||
AppToDelete: &AppDeletion{
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
Organization: "testorg",
|
|
||||||
Region: "US",
|
|
||||||
},
|
|
||||||
InstancesToDelete: []InstanceDeletion{
|
|
||||||
{
|
|
||||||
Name: "test-app-1.0.0-instance",
|
|
||||||
Organization: "testorg",
|
|
||||||
Region: "US",
|
|
||||||
CloudletOrg: "cloudletorg",
|
|
||||||
CloudletName: "cloudlet1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
TotalActions: 2,
|
|
||||||
EstimatedDuration: 10 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteDeletion_Success(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
logger := &TestLogger{}
|
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger))
|
|
||||||
|
|
||||||
plan := createTestDeletionPlan()
|
|
||||||
|
|
||||||
// Mock successful deletion operations
|
|
||||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
|
||||||
Return(nil)
|
|
||||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
|
||||||
Return(nil)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := manager.ExecuteDeletion(ctx, plan)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.True(t, result.Success)
|
|
||||||
assert.Len(t, result.CompletedActions, 2) // 1 instance + 1 app
|
|
||||||
assert.Len(t, result.FailedActions, 0)
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteDeletion_InstanceDeleteFails(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
logger := &TestLogger{}
|
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger))
|
|
||||||
|
|
||||||
plan := createTestDeletionPlan()
|
|
||||||
|
|
||||||
// Mock instance deletion failure
|
|
||||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
|
||||||
Return(fmt.Errorf("instance deletion failed"))
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := manager.ExecuteDeletion(ctx, plan)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.False(t, result.Success)
|
|
||||||
assert.Len(t, result.FailedActions, 1)
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteDeletion_OnlyInstances(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
logger := &TestLogger{}
|
|
||||||
manager := NewResourceManager(mockClient, WithLogger(logger))
|
|
||||||
|
|
||||||
plan := &DeletionPlan{
|
|
||||||
ConfigName: "test-deletion",
|
|
||||||
AppToDelete: nil, // No app to delete
|
|
||||||
InstancesToDelete: []InstanceDeletion{
|
|
||||||
{
|
|
||||||
Name: "test-app-1.0.0-instance",
|
|
||||||
Organization: "testorg",
|
|
||||||
Region: "US",
|
|
||||||
CloudletOrg: "cloudletorg",
|
|
||||||
CloudletName: "cloudlet1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
TotalActions: 1,
|
|
||||||
EstimatedDuration: 5 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock successful instance deletion
|
|
||||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
|
||||||
Return(nil)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := manager.ExecuteDeletion(ctx, plan)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.True(t, result.Success)
|
|
||||||
assert.Len(t, result.CompletedActions, 1)
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteDeletion_EmptyPlan(t *testing.T) {
|
|
||||||
mockClient := &MockResourceClient{}
|
|
||||||
manager := NewResourceManager(mockClient)
|
|
||||||
|
|
||||||
plan := &DeletionPlan{
|
|
||||||
ConfigName: "test-deletion",
|
|
||||||
AppToDelete: nil,
|
|
||||||
InstancesToDelete: []InstanceDeletion{},
|
|
||||||
TotalActions: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := manager.ExecuteDeletion(ctx, plan)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.True(t, result.Success)
|
|
||||||
assert.Len(t, result.CompletedActions, 0)
|
|
||||||
assert.Len(t, result.FailedActions, 0)
|
|
||||||
}
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
// ABOUTME: Deletion planner for EdgeConnect delete command
|
|
||||||
// ABOUTME: Analyzes current state to identify resources for deletion
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EdgeConnectClientInterface defines the methods needed for deletion planning
|
|
||||||
type EdgeConnectClientInterface interface {
|
|
||||||
ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error)
|
|
||||||
ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error)
|
|
||||||
DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error
|
|
||||||
DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Planner defines the interface for deletion planning
|
|
||||||
type Planner interface {
|
|
||||||
// Plan analyzes the configuration and current state to generate a deletion plan
|
|
||||||
Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error)
|
|
||||||
|
|
||||||
// PlanWithOptions allows customization of planning behavior
|
|
||||||
PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanOptions provides configuration for the planning process
|
|
||||||
type PlanOptions struct {
|
|
||||||
// DryRun indicates this is a planning-only operation
|
|
||||||
DryRun bool
|
|
||||||
|
|
||||||
// Timeout for API operations
|
|
||||||
Timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultPlanOptions returns sensible default planning options
|
|
||||||
func DefaultPlanOptions() PlanOptions {
|
|
||||||
return PlanOptions{
|
|
||||||
DryRun: false,
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
|
|
||||||
type EdgeConnectPlanner struct {
|
|
||||||
client EdgeConnectClientInterface
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPlanner creates a new EdgeConnect deletion planner
|
|
||||||
func NewPlanner(client EdgeConnectClientInterface) Planner {
|
|
||||||
return &EdgeConnectPlanner{
|
|
||||||
client: client,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan analyzes the configuration and generates a deletion plan
|
|
||||||
func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) {
|
|
||||||
return p.PlanWithOptions(ctx, config, DefaultPlanOptions())
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanWithOptions generates a deletion plan with custom options
|
|
||||||
func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) {
|
|
||||||
startTime := time.Now()
|
|
||||||
var warnings []string
|
|
||||||
|
|
||||||
// Create the deletion plan structure
|
|
||||||
plan := &DeletionPlan{
|
|
||||||
ConfigName: config.Metadata.Name,
|
|
||||||
CreatedAt: startTime,
|
|
||||||
DryRun: opts.DryRun,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the region from the first infra template
|
|
||||||
region := config.Spec.InfraTemplate[0].Region
|
|
||||||
|
|
||||||
// Step 1: Check if instances exist
|
|
||||||
instancesResult := p.findInstancesToDelete(ctx, config, region)
|
|
||||||
plan.InstancesToDelete = instancesResult.instances
|
|
||||||
if instancesResult.err != nil {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("Error querying instances: %v", instancesResult.err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Check if app exists
|
|
||||||
appResult := p.findAppToDelete(ctx, config, region)
|
|
||||||
plan.AppToDelete = appResult.app
|
|
||||||
if appResult.err != nil && !isNotFoundError(appResult.err) {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("Error querying app: %v", appResult.err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Calculate plan metadata
|
|
||||||
p.calculatePlanMetadata(plan)
|
|
||||||
|
|
||||||
// Step 4: Generate summary
|
|
||||||
plan.Summary = plan.GenerateSummary()
|
|
||||||
|
|
||||||
return &PlanResult{
|
|
||||||
Plan: plan,
|
|
||||||
Warnings: warnings,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type appQueryResult struct {
|
|
||||||
app *AppDeletion
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type instancesQueryResult struct {
|
|
||||||
instances []InstanceDeletion
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// findAppToDelete checks if the app exists and should be deleted
|
|
||||||
func (p *EdgeConnectPlanner) findAppToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) appQueryResult {
|
|
||||||
appKey := v2.AppKey{
|
|
||||||
Organization: config.Metadata.Organization,
|
|
||||||
Name: config.Metadata.Name,
|
|
||||||
Version: config.Metadata.AppVersion,
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := p.client.ShowApp(ctx, appKey, region)
|
|
||||||
if err != nil {
|
|
||||||
if isNotFoundError(err) {
|
|
||||||
return appQueryResult{app: nil, err: nil}
|
|
||||||
}
|
|
||||||
return appQueryResult{app: nil, err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return appQueryResult{
|
|
||||||
app: &AppDeletion{
|
|
||||||
Name: app.Key.Name,
|
|
||||||
Version: app.Key.Version,
|
|
||||||
Organization: app.Key.Organization,
|
|
||||||
Region: region,
|
|
||||||
},
|
|
||||||
err: nil,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// findInstancesToDelete finds all instances that match the config
|
|
||||||
func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) instancesQueryResult {
|
|
||||||
var allInstances []InstanceDeletion
|
|
||||||
|
|
||||||
// Query instances for each infra template
|
|
||||||
for _, infra := range config.Spec.InfraTemplate {
|
|
||||||
instanceKey := v2.AppInstanceKey{
|
|
||||||
Organization: config.Metadata.Organization,
|
|
||||||
Name: generateInstanceName(config.Metadata.Name, config.Metadata.AppVersion),
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: infra.CloudletOrg,
|
|
||||||
Name: infra.CloudletName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
appKey := v2.AppKey{Name: config.Metadata.Name}
|
|
||||||
|
|
||||||
instances, err := p.client.ShowAppInstances(ctx, instanceKey, appKey, infra.Region)
|
|
||||||
if err != nil {
|
|
||||||
// If it's a not found error, just continue
|
|
||||||
if isNotFoundError(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return instancesQueryResult{instances: nil, err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add found instances to the list
|
|
||||||
for _, inst := range instances {
|
|
||||||
allInstances = append(allInstances, InstanceDeletion{
|
|
||||||
Name: inst.Key.Name,
|
|
||||||
Organization: inst.Key.Organization,
|
|
||||||
Region: infra.Region,
|
|
||||||
CloudletOrg: inst.Key.CloudletKey.Organization,
|
|
||||||
CloudletName: inst.Key.CloudletKey.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return instancesQueryResult{
|
|
||||||
instances: allInstances,
|
|
||||||
err: nil,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculatePlanMetadata calculates the total actions and estimated duration
|
|
||||||
func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeletionPlan) {
|
|
||||||
totalActions := 0
|
|
||||||
|
|
||||||
if plan.AppToDelete != nil {
|
|
||||||
totalActions++
|
|
||||||
}
|
|
||||||
|
|
||||||
totalActions += len(plan.InstancesToDelete)
|
|
||||||
|
|
||||||
plan.TotalActions = totalActions
|
|
||||||
|
|
||||||
// Estimate duration: ~5 seconds per instance, ~3 seconds for app
|
|
||||||
estimatedSeconds := len(plan.InstancesToDelete) * 5
|
|
||||||
if plan.AppToDelete != nil {
|
|
||||||
estimatedSeconds += 3
|
|
||||||
}
|
|
||||||
plan.EstimatedDuration = time.Duration(estimatedSeconds) * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateInstanceName creates an instance name from app name and version
|
|
||||||
func generateInstanceName(appName, appVersion string) string {
|
|
||||||
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isNotFoundError checks if an error is a 404 not found error
|
|
||||||
func isNotFoundError(err error) bool {
|
|
||||||
if apiErr, ok := err.(*v2.APIError); ok {
|
|
||||||
return apiErr.StatusCode == 404
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanResult represents the result of a deletion planning operation
|
|
||||||
type PlanResult struct {
|
|
||||||
// Plan is the generated deletion plan
|
|
||||||
Plan *DeletionPlan
|
|
||||||
|
|
||||||
// Error if planning failed
|
|
||||||
Error error
|
|
||||||
|
|
||||||
// Warnings encountered during planning
|
|
||||||
Warnings []string
|
|
||||||
}
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
// ABOUTME: Tests for EdgeConnect deletion planner with mock scenarios
|
|
||||||
// ABOUTME: Tests deletion planning logic and resource discovery
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
|
||||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockEdgeConnectClient is a mock implementation of the EdgeConnect client
|
|
||||||
type MockEdgeConnectClient struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) {
|
|
||||||
args := m.Called(ctx, appKey, region)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return v2.App{}, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(v2.App), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) {
|
|
||||||
args := m.Called(ctx, instanceKey, region)
|
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).([]v2.AppInstance), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
|
|
||||||
args := m.Called(ctx, appKey, region)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
|
|
||||||
args := m.Called(ctx, instanceKey, region)
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTestConfig(t *testing.T) *config.EdgeConnectConfig {
|
|
||||||
// Create temporary manifest file
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
|
|
||||||
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
|
|
||||||
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return &config.EdgeConnectConfig{
|
|
||||||
Kind: "edgeconnect-deployment",
|
|
||||||
Metadata: config.Metadata{
|
|
||||||
Name: "test-app",
|
|
||||||
AppVersion: "1.0.0",
|
|
||||||
Organization: "testorg",
|
|
||||||
},
|
|
||||||
Spec: config.Spec{
|
|
||||||
K8sApp: &config.K8sApp{
|
|
||||||
ManifestFile: manifestFile,
|
|
||||||
},
|
|
||||||
InfraTemplate: []config.InfraTemplate{
|
|
||||||
{
|
|
||||||
Region: "US",
|
|
||||||
CloudletOrg: "TestCloudletOrg",
|
|
||||||
CloudletName: "TestCloudlet",
|
|
||||||
FlavorName: "small",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewPlanner(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
|
|
||||||
assert.NotNil(t, planner)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanDeletion_WithExistingResources(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
// Mock existing app
|
|
||||||
existingApp := v2.App{
|
|
||||||
Key: v2.AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Deployment: "kubernetes",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock existing instances
|
|
||||||
existingInstances := []v2.AppInstance{
|
|
||||||
{
|
|
||||||
Key: v2.AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "test-app-1.0.0-instance",
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: "TestCloudletOrg",
|
|
||||||
Name: "TestCloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppKey: v2.AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "test-app",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
|
||||||
Return(existingApp, nil)
|
|
||||||
|
|
||||||
mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
|
||||||
Return(existingInstances, nil)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.Plan(ctx, testConfig)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.NotNil(t, result.Plan)
|
|
||||||
|
|
||||||
plan := result.Plan
|
|
||||||
assert.Equal(t, "test-app", plan.ConfigName)
|
|
||||||
assert.NotNil(t, plan.AppToDelete)
|
|
||||||
assert.Equal(t, "test-app", plan.AppToDelete.Name)
|
|
||||||
assert.Equal(t, "1.0.0", plan.AppToDelete.Version)
|
|
||||||
assert.Equal(t, "testorg", plan.AppToDelete.Organization)
|
|
||||||
|
|
||||||
require.Len(t, plan.InstancesToDelete, 1)
|
|
||||||
assert.Equal(t, "test-app-1.0.0-instance", plan.InstancesToDelete[0].Name)
|
|
||||||
assert.Equal(t, "testorg", plan.InstancesToDelete[0].Organization)
|
|
||||||
|
|
||||||
assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance
|
|
||||||
assert.False(t, plan.IsEmpty())
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanDeletion_NoResourcesExist(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
// Mock API calls to return "not found" errors
|
|
||||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
|
||||||
Return(v2.App{}, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
|
||||||
|
|
||||||
mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
|
||||||
Return([]v2.AppInstance{}, nil)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.Plan(ctx, testConfig)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.NotNil(t, result.Plan)
|
|
||||||
|
|
||||||
plan := result.Plan
|
|
||||||
assert.Equal(t, "test-app", plan.ConfigName)
|
|
||||||
assert.Nil(t, plan.AppToDelete)
|
|
||||||
assert.Len(t, plan.InstancesToDelete, 0)
|
|
||||||
assert.Equal(t, 0, plan.TotalActions)
|
|
||||||
assert.True(t, plan.IsEmpty())
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanDeletion_OnlyInstancesExist(t *testing.T) {
|
|
||||||
mockClient := &MockEdgeConnectClient{}
|
|
||||||
planner := NewPlanner(mockClient)
|
|
||||||
testConfig := createTestConfig(t)
|
|
||||||
|
|
||||||
// Mock existing instances but no app
|
|
||||||
existingInstances := []v2.AppInstance{
|
|
||||||
{
|
|
||||||
Key: v2.AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "test-app-1.0.0-instance",
|
|
||||||
CloudletKey: v2.CloudletKey{
|
|
||||||
Organization: "TestCloudletOrg",
|
|
||||||
Name: "TestCloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
|
||||||
Return(v2.App{}, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
|
||||||
|
|
||||||
mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
|
||||||
Return(existingInstances, nil)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := planner.Plan(ctx, testConfig)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.NotNil(t, result.Plan)
|
|
||||||
|
|
||||||
plan := result.Plan
|
|
||||||
assert.Nil(t, plan.AppToDelete)
|
|
||||||
assert.Len(t, plan.InstancesToDelete, 1)
|
|
||||||
assert.Equal(t, 1, plan.TotalActions)
|
|
||||||
assert.False(t, plan.IsEmpty())
|
|
||||||
|
|
||||||
mockClient.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
// ABOUTME: Deletion planning types for EdgeConnect delete command
|
|
||||||
// ABOUTME: Defines structures for deletion plans and deletion results
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DeletionPlan represents the complete deletion plan for a configuration
|
|
||||||
type DeletionPlan struct {
|
|
||||||
// ConfigName is the name from metadata
|
|
||||||
ConfigName string
|
|
||||||
|
|
||||||
// AppToDelete defines the app that will be deleted (nil if app doesn't exist)
|
|
||||||
AppToDelete *AppDeletion
|
|
||||||
|
|
||||||
// InstancesToDelete defines the instances that will be deleted
|
|
||||||
InstancesToDelete []InstanceDeletion
|
|
||||||
|
|
||||||
// Summary provides a human-readable summary of the plan
|
|
||||||
Summary string
|
|
||||||
|
|
||||||
// TotalActions is the count of all actions that will be performed
|
|
||||||
TotalActions int
|
|
||||||
|
|
||||||
// EstimatedDuration is the estimated time to complete the deletion
|
|
||||||
EstimatedDuration time.Duration
|
|
||||||
|
|
||||||
// CreatedAt timestamp when the plan was created
|
|
||||||
CreatedAt time.Time
|
|
||||||
|
|
||||||
// DryRun indicates if this is a dry-run plan
|
|
||||||
DryRun bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppDeletion represents an application to be deleted
|
|
||||||
type AppDeletion struct {
|
|
||||||
// Name of the application
|
|
||||||
Name string
|
|
||||||
|
|
||||||
// Version of the application
|
|
||||||
Version string
|
|
||||||
|
|
||||||
// Organization that owns the app
|
|
||||||
Organization string
|
|
||||||
|
|
||||||
// Region where the app is deployed
|
|
||||||
Region string
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstanceDeletion represents an application instance to be deleted
|
|
||||||
type InstanceDeletion struct {
|
|
||||||
// Name of the instance
|
|
||||||
Name string
|
|
||||||
|
|
||||||
// Organization that owns the instance
|
|
||||||
Organization string
|
|
||||||
|
|
||||||
// Region where the instance is deployed
|
|
||||||
Region string
|
|
||||||
|
|
||||||
// CloudletOrg that hosts the cloudlet
|
|
||||||
CloudletOrg string
|
|
||||||
|
|
||||||
// CloudletName where the instance is running
|
|
||||||
CloudletName string
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeletionResult represents the result of a deletion operation
|
|
||||||
type DeletionResult struct {
|
|
||||||
// Plan that was executed
|
|
||||||
Plan *DeletionPlan
|
|
||||||
|
|
||||||
// Success indicates if the deletion was successful
|
|
||||||
Success bool
|
|
||||||
|
|
||||||
// CompletedActions lists actions that were successfully completed
|
|
||||||
CompletedActions []DeletionActionResult
|
|
||||||
|
|
||||||
// FailedActions lists actions that failed
|
|
||||||
FailedActions []DeletionActionResult
|
|
||||||
|
|
||||||
// Error that caused the deletion to fail (if any)
|
|
||||||
Error error
|
|
||||||
|
|
||||||
// Duration taken to execute the plan
|
|
||||||
Duration time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeletionActionResult represents the result of executing a single deletion action
|
|
||||||
type DeletionActionResult struct {
|
|
||||||
// Type of resource that was deleted ("app" or "instance")
|
|
||||||
Type string
|
|
||||||
|
|
||||||
// Target describes what was being deleted
|
|
||||||
Target string
|
|
||||||
|
|
||||||
// Success indicates if the action succeeded
|
|
||||||
Success bool
|
|
||||||
|
|
||||||
// Error if the action failed
|
|
||||||
Error error
|
|
||||||
|
|
||||||
// Duration taken to complete the action
|
|
||||||
Duration time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty returns true if the deletion plan has no actions to perform
|
|
||||||
func (dp *DeletionPlan) IsEmpty() bool {
|
|
||||||
return dp.AppToDelete == nil && len(dp.InstancesToDelete) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateSummary creates a human-readable summary of the deletion plan
|
|
||||||
func (dp *DeletionPlan) GenerateSummary() string {
|
|
||||||
if dp.IsEmpty() {
|
|
||||||
return "No resources found to delete"
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("Deletion plan for '%s':\n", dp.ConfigName))
|
|
||||||
|
|
||||||
// Instance actions
|
|
||||||
if len(dp.InstancesToDelete) > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf("- DELETE %d instance(s)\n", len(dp.InstancesToDelete)))
|
|
||||||
cloudletSet := make(map[string]bool)
|
|
||||||
for _, inst := range dp.InstancesToDelete {
|
|
||||||
key := fmt.Sprintf("%s:%s", inst.CloudletOrg, inst.CloudletName)
|
|
||||||
cloudletSet[key] = true
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf(" Across %d cloudlet(s)\n", len(cloudletSet)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// App action
|
|
||||||
if dp.AppToDelete != nil {
|
|
||||||
sb.WriteString(fmt.Sprintf("- DELETE application '%s' version %s\n",
|
|
||||||
dp.AppToDelete.Name, dp.AppToDelete.Version))
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate checks if the deletion plan is valid
|
|
||||||
func (dp *DeletionPlan) Validate() error {
|
|
||||||
if dp.ConfigName == "" {
|
|
||||||
return fmt.Errorf("deletion plan must have a config name")
|
|
||||||
}
|
|
||||||
|
|
||||||
if dp.IsEmpty() {
|
|
||||||
return fmt.Errorf("deletion plan has no resources to delete")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDeletionPlan_IsEmpty(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
plan *DeletionPlan
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty plan with no resources",
|
|
||||||
plan: &DeletionPlan{
|
|
||||||
ConfigName: "test-config",
|
|
||||||
AppToDelete: nil,
|
|
||||||
InstancesToDelete: []InstanceDeletion{},
|
|
||||||
},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "plan with app deletion",
|
|
||||||
plan: &DeletionPlan{
|
|
||||||
ConfigName: "test-config",
|
|
||||||
AppToDelete: &AppDeletion{
|
|
||||||
Name: "test-app",
|
|
||||||
Organization: "test-org",
|
|
||||||
Version: "1.0",
|
|
||||||
Region: "US",
|
|
||||||
},
|
|
||||||
InstancesToDelete: []InstanceDeletion{},
|
|
||||||
},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "plan with instance deletion",
|
|
||||||
plan: &DeletionPlan{
|
|
||||||
ConfigName: "test-config",
|
|
||||||
AppToDelete: nil,
|
|
||||||
InstancesToDelete: []InstanceDeletion{
|
|
||||||
{
|
|
||||||
Name: "test-instance",
|
|
||||||
Organization: "test-org",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := tt.plan.IsEmpty()
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeletionPlan_GenerateSummary(t *testing.T) {
|
|
||||||
plan := &DeletionPlan{
|
|
||||||
ConfigName: "test-config",
|
|
||||||
AppToDelete: &AppDeletion{
|
|
||||||
Name: "test-app",
|
|
||||||
Organization: "test-org",
|
|
||||||
Version: "1.0",
|
|
||||||
Region: "US",
|
|
||||||
},
|
|
||||||
InstancesToDelete: []InstanceDeletion{
|
|
||||||
{
|
|
||||||
Name: "test-instance-1",
|
|
||||||
Organization: "test-org",
|
|
||||||
CloudletName: "cloudlet-1",
|
|
||||||
CloudletOrg: "cloudlet-org",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "test-instance-2",
|
|
||||||
Organization: "test-org",
|
|
||||||
CloudletName: "cloudlet-2",
|
|
||||||
CloudletOrg: "cloudlet-org",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
TotalActions: 3,
|
|
||||||
EstimatedDuration: 30 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
summary := plan.GenerateSummary()
|
|
||||||
|
|
||||||
assert.Contains(t, summary, "test-config")
|
|
||||||
assert.Contains(t, summary, "DELETE application 'test-app'")
|
|
||||||
assert.Contains(t, summary, "DELETE 2 instance(s)")
|
|
||||||
}
|
|
||||||
31
internal/domain/app.go
Normal file
31
internal/domain/app.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
// AppKey uniquely identifies an application
|
||||||
|
type AppKey struct {
|
||||||
|
Organization string
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// App represents an application definition
|
||||||
|
type App struct {
|
||||||
|
Key AppKey
|
||||||
|
Deployment string
|
||||||
|
ImageType string
|
||||||
|
ImagePath string
|
||||||
|
AllowServerless bool
|
||||||
|
DefaultFlavor Flavor
|
||||||
|
ServerlessConfig interface{}
|
||||||
|
DeploymentGenerator string
|
||||||
|
DeploymentManifest string
|
||||||
|
RequiredOutboundConnections []SecurityRule
|
||||||
|
Fields []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityRule defines network access rules
|
||||||
|
type SecurityRule struct {
|
||||||
|
PortRangeMax int
|
||||||
|
PortRangeMin int
|
||||||
|
Protocol string
|
||||||
|
RemoteCIDR string
|
||||||
|
}
|
||||||
18
internal/domain/app_instance.go
Normal file
18
internal/domain/app_instance.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
// AppInstanceKey uniquely identifies an application instance
|
||||||
|
type AppInstanceKey struct {
|
||||||
|
Organization string
|
||||||
|
Name string
|
||||||
|
CloudletKey CloudletKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppInstance represents a deployed application instance
|
||||||
|
type AppInstance struct {
|
||||||
|
Key AppInstanceKey
|
||||||
|
AppKey AppKey
|
||||||
|
Flavor Flavor
|
||||||
|
State string
|
||||||
|
PowerState string
|
||||||
|
Fields []string
|
||||||
|
}
|
||||||
26
internal/domain/cloudlet.go
Normal file
26
internal/domain/cloudlet.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
// CloudletKey uniquely identifies a cloudlet
|
||||||
|
type CloudletKey struct {
|
||||||
|
Organization string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloudlet represents edge infrastructure
|
||||||
|
type Cloudlet struct {
|
||||||
|
Key CloudletKey
|
||||||
|
Location Location
|
||||||
|
IpSupport string
|
||||||
|
NumDynamicIps int32
|
||||||
|
State string
|
||||||
|
Flavor Flavor
|
||||||
|
PhysicalName string
|
||||||
|
Region string
|
||||||
|
NotifySrvAddr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location represents geographical coordinates
|
||||||
|
type Location struct {
|
||||||
|
Latitude float64
|
||||||
|
Longitude float64
|
||||||
|
}
|
||||||
309
internal/domain/errors.go
Normal file
309
internal/domain/errors.go
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
// Package domain contains domain-specific error types for the EdgeConnect client
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorCode represents different types of domain errors
|
||||||
|
type ErrorCode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Resource errors
|
||||||
|
ErrResourceNotFound ErrorCode = iota
|
||||||
|
ErrResourceAlreadyExists
|
||||||
|
ErrResourceConflict
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
ErrValidationFailed
|
||||||
|
ErrInvalidConfiguration
|
||||||
|
ErrInvalidInput
|
||||||
|
|
||||||
|
// Business logic errors
|
||||||
|
ErrQuotaExceeded
|
||||||
|
ErrInsufficientPermissions
|
||||||
|
ErrOperationNotAllowed
|
||||||
|
|
||||||
|
// Infrastructure errors
|
||||||
|
ErrNetworkError
|
||||||
|
ErrAuthenticationFailed
|
||||||
|
ErrServiceUnavailable
|
||||||
|
ErrTimeout
|
||||||
|
|
||||||
|
// Internal errors
|
||||||
|
ErrInternalError
|
||||||
|
ErrUnknownError
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns a human-readable string representation of the error code
|
||||||
|
func (e ErrorCode) String() string {
|
||||||
|
switch e {
|
||||||
|
case ErrResourceNotFound:
|
||||||
|
return "RESOURCE_NOT_FOUND"
|
||||||
|
case ErrResourceAlreadyExists:
|
||||||
|
return "RESOURCE_ALREADY_EXISTS"
|
||||||
|
case ErrResourceConflict:
|
||||||
|
return "RESOURCE_CONFLICT"
|
||||||
|
case ErrValidationFailed:
|
||||||
|
return "VALIDATION_FAILED"
|
||||||
|
case ErrInvalidConfiguration:
|
||||||
|
return "INVALID_CONFIGURATION"
|
||||||
|
case ErrInvalidInput:
|
||||||
|
return "INVALID_INPUT"
|
||||||
|
case ErrQuotaExceeded:
|
||||||
|
return "QUOTA_EXCEEDED"
|
||||||
|
case ErrInsufficientPermissions:
|
||||||
|
return "INSUFFICIENT_PERMISSIONS"
|
||||||
|
case ErrOperationNotAllowed:
|
||||||
|
return "OPERATION_NOT_ALLOWED"
|
||||||
|
case ErrNetworkError:
|
||||||
|
return "NETWORK_ERROR"
|
||||||
|
case ErrAuthenticationFailed:
|
||||||
|
return "AUTHENTICATION_FAILED"
|
||||||
|
case ErrServiceUnavailable:
|
||||||
|
return "SERVICE_UNAVAILABLE"
|
||||||
|
case ErrTimeout:
|
||||||
|
return "TIMEOUT"
|
||||||
|
case ErrInternalError:
|
||||||
|
return "INTERNAL_ERROR"
|
||||||
|
case ErrUnknownError:
|
||||||
|
return "UNKNOWN_ERROR"
|
||||||
|
default:
|
||||||
|
return "UNDEFINED_ERROR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainError represents a domain-specific error with detailed context
|
||||||
|
type DomainError struct {
|
||||||
|
Code ErrorCode `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
Cause error `json:"-"`
|
||||||
|
Context map[string]interface{} `json:"context,omitempty"`
|
||||||
|
Resource *ResourceIdentifier `json:"resource,omitempty"`
|
||||||
|
Operation string `json:"operation,omitempty"`
|
||||||
|
Retryable bool `json:"retryable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResourceIdentifier provides context about the resource involved in the error
|
||||||
|
type ResourceIdentifier struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Organization string `json:"organization,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements the error interface
|
||||||
|
func (e *DomainError) Error() string {
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
if e.Operation != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("operation %s failed", e.Operation))
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Resource != nil {
|
||||||
|
parts = append(parts, fmt.Sprintf("resource %s", e.resourceString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = append(parts, e.Message)
|
||||||
|
|
||||||
|
if e.Details != "" {
|
||||||
|
parts = append(parts, e.Details)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := strings.Join(parts, ": ")
|
||||||
|
|
||||||
|
if e.Cause != nil {
|
||||||
|
result = fmt.Sprintf("%s (caused by: %v)", result, e.Cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the underlying cause for error wrapping
|
||||||
|
func (e *DomainError) Unwrap() error {
|
||||||
|
return e.Cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is checks if the error matches a specific error code
|
||||||
|
func (e *DomainError) Is(target error) bool {
|
||||||
|
if de, ok := target.(*DomainError); ok {
|
||||||
|
return e.Code == de.Code
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRetryable indicates whether the operation should be retried
|
||||||
|
func (e *DomainError) IsRetryable() bool {
|
||||||
|
return e.Retryable
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithContext adds context information to the error
|
||||||
|
func (e *DomainError) WithContext(key string, value interface{}) *DomainError {
|
||||||
|
if e.Context == nil {
|
||||||
|
e.Context = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
e.Context[key] = value
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDetails adds additional details to the error
|
||||||
|
func (e *DomainError) WithDetails(details string) *DomainError {
|
||||||
|
e.Details = details
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DomainError) resourceString() string {
|
||||||
|
if e.Resource == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := []string{e.Resource.Type}
|
||||||
|
|
||||||
|
if e.Resource.Organization != "" && e.Resource.Name != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s/%s", e.Resource.Organization, e.Resource.Name))
|
||||||
|
} else if e.Resource.Name != "" {
|
||||||
|
parts = append(parts, e.Resource.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Resource.Version != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("version %s", e.Resource.Version))
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Resource.Region != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("in region %s", e.Resource.Region))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error creation helpers
|
||||||
|
|
||||||
|
// NewDomainError creates a new domain error with the specified code and message
|
||||||
|
func NewDomainError(code ErrorCode, message string) *DomainError {
|
||||||
|
return &DomainError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Retryable: isRetryableByDefault(code),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDomainErrorWithCause creates a new domain error with an underlying cause
|
||||||
|
func NewDomainErrorWithCause(code ErrorCode, message string, cause error) *DomainError {
|
||||||
|
return &DomainError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Cause: cause,
|
||||||
|
Retryable: isRetryableByDefault(code),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResourceError creates a domain error for resource-related operations
|
||||||
|
func NewResourceError(code ErrorCode, operation string, resource *ResourceIdentifier, message string) *DomainError {
|
||||||
|
return &DomainError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Operation: operation,
|
||||||
|
Resource: resource,
|
||||||
|
Retryable: isRetryableByDefault(code),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRetryableByDefault(code ErrorCode) bool {
|
||||||
|
switch code {
|
||||||
|
case ErrNetworkError, ErrServiceUnavailable, ErrTimeout, ErrInternalError:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predefined errors for common scenarios
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Resource errors
|
||||||
|
ErrAppNotFound = NewDomainError(ErrResourceNotFound, "application not found")
|
||||||
|
ErrAppExists = NewDomainError(ErrResourceAlreadyExists, "application already exists")
|
||||||
|
ErrInstanceNotFound = NewDomainError(ErrResourceNotFound, "app instance not found")
|
||||||
|
ErrInstanceExists = NewDomainError(ErrResourceAlreadyExists, "app instance already exists")
|
||||||
|
ErrCloudletNotFound = NewDomainError(ErrResourceNotFound, "cloudlet not found")
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
ErrInvalidAppKey = NewDomainError(ErrValidationFailed, "invalid application key")
|
||||||
|
ErrInvalidInstanceKey = NewDomainError(ErrValidationFailed, "invalid app instance key")
|
||||||
|
ErrInvalidCloudletKey = NewDomainError(ErrValidationFailed, "invalid cloudlet key")
|
||||||
|
ErrMissingRegion = NewDomainError(ErrValidationFailed, "region is required")
|
||||||
|
|
||||||
|
// Business logic errors
|
||||||
|
ErrDeploymentFailed = NewDomainError(ErrOperationNotAllowed, "deployment failed")
|
||||||
|
ErrRollbackFailed = NewDomainError(ErrOperationNotAllowed, "rollback failed")
|
||||||
|
ErrPlanningFailed = NewDomainError(ErrOperationNotAllowed, "deployment planning failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper functions for creating specific error scenarios
|
||||||
|
|
||||||
|
// NewAppError creates an error related to application operations
|
||||||
|
func NewAppError(code ErrorCode, operation string, appKey AppKey, region string, message string) *DomainError {
|
||||||
|
resource := &ResourceIdentifier{
|
||||||
|
Type: "app",
|
||||||
|
Organization: appKey.Organization,
|
||||||
|
Name: appKey.Name,
|
||||||
|
Version: appKey.Version,
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
return NewResourceError(code, operation, resource, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInstanceError creates an error related to app instance operations
|
||||||
|
func NewInstanceError(code ErrorCode, operation string, instanceKey AppInstanceKey, region string, message string) *DomainError {
|
||||||
|
resource := &ResourceIdentifier{
|
||||||
|
Type: "app-instance",
|
||||||
|
Organization: instanceKey.Organization,
|
||||||
|
Name: instanceKey.Name,
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
return NewResourceError(code, operation, resource, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCloudletError creates an error related to cloudlet operations
|
||||||
|
func NewCloudletError(code ErrorCode, operation string, cloudletKey CloudletKey, region string, message string) *DomainError {
|
||||||
|
resource := &ResourceIdentifier{
|
||||||
|
Type: "cloudlet",
|
||||||
|
Organization: cloudletKey.Organization,
|
||||||
|
Name: cloudletKey.Name,
|
||||||
|
Region: region,
|
||||||
|
}
|
||||||
|
return NewResourceError(code, operation, resource, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error checking utilities
|
||||||
|
|
||||||
|
// IsNotFoundError checks if an error indicates a resource was not found
|
||||||
|
func IsNotFoundError(err error) bool {
|
||||||
|
var de *DomainError
|
||||||
|
return errors.As(err, &de) && de.Code == ErrResourceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidationError checks if an error is a validation error
|
||||||
|
func IsValidationError(err error) bool {
|
||||||
|
var de *DomainError
|
||||||
|
return errors.As(err, &de) && (de.Code == ErrValidationFailed || de.Code == ErrInvalidInput || de.Code == ErrInvalidConfiguration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRetryableError checks if an error is retryable
|
||||||
|
func IsRetryableError(err error) bool {
|
||||||
|
var de *DomainError
|
||||||
|
if errors.As(err, &de) {
|
||||||
|
return de.IsRetryable()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAuthenticationError checks if an error is authentication-related
|
||||||
|
func IsAuthenticationError(err error) bool {
|
||||||
|
var de *DomainError
|
||||||
|
return errors.As(err, &de) && de.Code == ErrAuthenticationFailed
|
||||||
|
}
|
||||||
207
internal/domain/errors_test.go
Normal file
207
internal/domain/errors_test.go
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainError_Creation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
code ErrorCode
|
||||||
|
message string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple error",
|
||||||
|
code: ErrResourceNotFound,
|
||||||
|
message: "test resource not found",
|
||||||
|
expected: "test resource not found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "validation error",
|
||||||
|
code: ErrValidationFailed,
|
||||||
|
message: "invalid input",
|
||||||
|
expected: "invalid input",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := NewDomainError(tt.code, tt.message)
|
||||||
|
if err.Error() != tt.expected {
|
||||||
|
t.Errorf("Expected error message %q, got %q", tt.expected, err.Error())
|
||||||
|
}
|
||||||
|
if err.Code != tt.code {
|
||||||
|
t.Errorf("Expected error code %v, got %v", tt.code, err.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainError_WithContext(t *testing.T) {
|
||||||
|
err := NewDomainError(ErrResourceNotFound, "test error")
|
||||||
|
err = err.WithContext("user_id", "123")
|
||||||
|
err = err.WithContext("operation", "create")
|
||||||
|
|
||||||
|
if len(err.Context) != 2 {
|
||||||
|
t.Errorf("Expected 2 context items, got %d", len(err.Context))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err.Context["user_id"] != "123" {
|
||||||
|
t.Errorf("Expected user_id to be '123', got %v", err.Context["user_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainError_WithDetails(t *testing.T) {
|
||||||
|
err := NewDomainError(ErrValidationFailed, "validation failed")
|
||||||
|
err = err.WithDetails("name field is required")
|
||||||
|
|
||||||
|
expectedError := "validation failed: name field is required"
|
||||||
|
if err.Error() != expectedError {
|
||||||
|
t.Errorf("Expected error %q, got %q", expectedError, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainError_WithCause(t *testing.T) {
|
||||||
|
cause := errors.New("network timeout")
|
||||||
|
err := NewDomainErrorWithCause(ErrNetworkError, "operation failed", cause)
|
||||||
|
|
||||||
|
if err.Cause != cause {
|
||||||
|
t.Error("Expected cause to be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, cause) {
|
||||||
|
t.Error("Expected error to wrap the cause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppError_Creation(t *testing.T) {
|
||||||
|
appKey := AppKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-app",
|
||||||
|
Version: "1.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := NewAppError(ErrResourceNotFound, "ShowApp", appKey, "US", "not found")
|
||||||
|
|
||||||
|
expected := "operation ShowApp failed: resource app test-org/test-app version 1.0.0 in region US: not found"
|
||||||
|
if err.Error() != expected {
|
||||||
|
t.Errorf("Expected error %q, got %q", expected, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err.Resource.Type != "app" {
|
||||||
|
t.Errorf("Expected resource type 'app', got %q", err.Resource.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceError_Creation(t *testing.T) {
|
||||||
|
instanceKey := AppInstanceKey{
|
||||||
|
Organization: "test-org",
|
||||||
|
Name: "test-instance",
|
||||||
|
CloudletKey: CloudletKey{
|
||||||
|
Organization: "cloudlet-org",
|
||||||
|
Name: "cloudlet-name",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := NewInstanceError(ErrResourceNotFound, "ShowAppInstance", instanceKey, "US", "not found")
|
||||||
|
|
||||||
|
if err.Resource.Type != "app-instance" {
|
||||||
|
t.Errorf("Expected resource type 'app-instance', got %q", err.Resource.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err.Operation != "ShowAppInstance" {
|
||||||
|
t.Errorf("Expected operation 'ShowAppInstance', got %q", err.Operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorChecking_Functions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
checkFn func(error) bool
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "IsNotFoundError with not found error",
|
||||||
|
err: NewDomainError(ErrResourceNotFound, "not found"),
|
||||||
|
checkFn: IsNotFoundError,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IsNotFoundError with validation error",
|
||||||
|
err: NewDomainError(ErrValidationFailed, "invalid"),
|
||||||
|
checkFn: IsNotFoundError,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IsValidationError with validation error",
|
||||||
|
err: NewDomainError(ErrValidationFailed, "invalid"),
|
||||||
|
checkFn: IsValidationError,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IsRetryableError with network error",
|
||||||
|
err: NewDomainError(ErrNetworkError, "connection failed"),
|
||||||
|
checkFn: IsRetryableError,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IsRetryableError with validation error",
|
||||||
|
err: NewDomainError(ErrValidationFailed, "invalid"),
|
||||||
|
checkFn: IsRetryableError,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IsAuthenticationError with auth error",
|
||||||
|
err: NewDomainError(ErrAuthenticationFailed, "unauthorized"),
|
||||||
|
checkFn: IsAuthenticationError,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.checkFn(tt.err)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorCode_String(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
code ErrorCode
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{ErrResourceNotFound, "RESOURCE_NOT_FOUND"},
|
||||||
|
{ErrValidationFailed, "VALIDATION_FAILED"},
|
||||||
|
{ErrNetworkError, "NETWORK_ERROR"},
|
||||||
|
{ErrUnknownError, "UNKNOWN_ERROR"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.expected, func(t *testing.T) {
|
||||||
|
if tt.code.String() != tt.expected {
|
||||||
|
t.Errorf("Expected %q, got %q", tt.expected, tt.code.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPredefinedErrors(t *testing.T) {
|
||||||
|
// Test that predefined errors have correct codes
|
||||||
|
if ErrAppNotFound.Code != ErrResourceNotFound {
|
||||||
|
t.Error("ErrAppNotFound should have ErrResourceNotFound code")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ErrInvalidAppKey.Code != ErrValidationFailed {
|
||||||
|
t.Error("ErrInvalidAppKey should have ErrValidationFailed code")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ErrDeploymentFailed.Code != ErrOperationNotAllowed {
|
||||||
|
t.Error("ErrDeploymentFailed should have ErrOperationNotAllowed code")
|
||||||
|
}
|
||||||
|
}
|
||||||
6
internal/domain/flavor.go
Normal file
6
internal/domain/flavor.go
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
// Flavor defines resource allocation for instances
|
||||||
|
type Flavor struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
9
internal/domain/organization.go
Normal file
9
internal/domain/organization.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
// Organization represents the core business object for an organization.
|
||||||
|
// It contains identifying information such as name, address, and phone number.
|
||||||
|
type Organization struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -11,56 +10,47 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseExampleConfig(t *testing.T) {
|
func TestParseExampleConfig(t *testing.T) {
|
||||||
|
// The base path is relative to the location of this test file
|
||||||
parser := NewParser()
|
parser := NewParser()
|
||||||
|
cfg, _, err := parser.ParseFile("../../../sdk/examples/comprehensive/EdgeConnectConfig.yaml")
|
||||||
// Parse the actual example file (now that we've created the manifest file)
|
|
||||||
examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig_v1.yaml")
|
|
||||||
config, parsedManifest, err := parser.ParseFile(examplePath)
|
|
||||||
|
|
||||||
// This should now succeed with full validation
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, config)
|
require.NotNil(t, cfg)
|
||||||
require.NotEmpty(t, parsedManifest)
|
|
||||||
|
|
||||||
// Validate the parsed structure
|
// Basic validation
|
||||||
assert.Equal(t, "edgeconnect-deployment", config.Kind)
|
assert.Equal(t, "edgeconnect-deployment", cfg.Kind)
|
||||||
assert.Equal(t, "edge-app-demo", config.Metadata.Name)
|
assert.Equal(t, "edge-app-demo", cfg.Metadata.Name)
|
||||||
|
assert.NotNil(t, cfg.Spec.K8sApp)
|
||||||
// Check k8s app configuration
|
assert.NotEmpty(t, cfg.Spec.K8sApp.ManifestFile)
|
||||||
require.NotNil(t, config.Spec.K8sApp)
|
|
||||||
assert.Equal(t, "1.0.0", config.Metadata.AppVersion)
|
|
||||||
// Note: ManifestFile path should be resolved to absolute path
|
|
||||||
assert.Contains(t, config.Spec.K8sApp.ManifestFile, "k8s-deployment.yaml")
|
|
||||||
|
|
||||||
// Check infrastructure template
|
// Check infrastructure template
|
||||||
require.Len(t, config.Spec.InfraTemplate, 1)
|
require.Len(t, cfg.Spec.InfraTemplate, 1)
|
||||||
infra := config.Spec.InfraTemplate[0]
|
infra := cfg.Spec.InfraTemplate[0]
|
||||||
assert.Equal(t, "EU", infra.Region)
|
assert.Equal(t, "EU", infra.Region)
|
||||||
assert.Equal(t, "TelekomOP", infra.CloudletOrg)
|
assert.Equal(t, "TelekomOP", infra.CloudletOrg)
|
||||||
assert.Equal(t, "Munich", infra.CloudletName)
|
assert.Equal(t, "Munich", infra.CloudletName)
|
||||||
assert.Equal(t, "EU.small", infra.FlavorName)
|
assert.Equal(t, "EU.small", infra.FlavorName)
|
||||||
|
|
||||||
// Check network configuration
|
// Check network configuration
|
||||||
require.NotNil(t, config.Spec.Network)
|
require.NotNil(t, cfg.Spec.Network)
|
||||||
require.Len(t, config.Spec.Network.OutboundConnections, 2)
|
require.Len(t, cfg.Spec.Network.OutboundConnections, 2)
|
||||||
|
|
||||||
conn1 := config.Spec.Network.OutboundConnections[0]
|
conn1 := cfg.Spec.Network.OutboundConnections[0]
|
||||||
assert.Equal(t, "tcp", conn1.Protocol)
|
assert.Equal(t, "tcp", conn1.Protocol)
|
||||||
assert.Equal(t, 80, conn1.PortRangeMin)
|
assert.Equal(t, 80, conn1.PortRangeMin)
|
||||||
assert.Equal(t, 80, conn1.PortRangeMax)
|
assert.Equal(t, 80, conn1.PortRangeMax)
|
||||||
assert.Equal(t, "0.0.0.0/0", conn1.RemoteCIDR)
|
assert.Equal(t, "0.0.0.0/0", conn1.RemoteCIDR)
|
||||||
|
|
||||||
conn2 := config.Spec.Network.OutboundConnections[1]
|
conn2 := cfg.Spec.Network.OutboundConnections[1]
|
||||||
assert.Equal(t, "tcp", conn2.Protocol)
|
assert.Equal(t, "tcp", conn2.Protocol)
|
||||||
assert.Equal(t, 443, conn2.PortRangeMin)
|
assert.Equal(t, 443, conn2.PortRangeMin)
|
||||||
assert.Equal(t, 443, conn2.PortRangeMax)
|
assert.Equal(t, 443, conn2.PortRangeMax)
|
||||||
assert.Equal(t, "0.0.0.0/0", conn2.RemoteCIDR)
|
assert.Equal(t, "0.0.0.0/0", conn2.RemoteCIDR)
|
||||||
|
|
||||||
// Test utility methods
|
// Test utility methods
|
||||||
assert.Equal(t, "edge-app-demo", config.Metadata.Name)
|
assert.Equal(t, "edge-app-demo", cfg.Metadata.Name)
|
||||||
assert.Contains(t, config.Spec.GetManifestFile(), "k8s-deployment.yaml")
|
assert.Contains(t, cfg.Spec.GetManifestFile(), "k8s-deployment.yaml")
|
||||||
assert.True(t, config.Spec.IsK8sApp())
|
assert.True(t, cfg.Spec.IsK8sApp())
|
||||||
assert.False(t, config.Spec.IsDockerApp())
|
assert.False(t, cfg.Spec.IsDockerApp())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateExampleStructure(t *testing.T) {
|
func TestValidateExampleStructure(t *testing.T) {
|
||||||
|
|
@ -70,8 +60,8 @@ func TestValidateExampleStructure(t *testing.T) {
|
||||||
config := &EdgeConnectConfig{
|
config := &EdgeConnectConfig{
|
||||||
Kind: "edgeconnect-deployment",
|
Kind: "edgeconnect-deployment",
|
||||||
Metadata: Metadata{
|
Metadata: Metadata{
|
||||||
Name: "edge-app-demo",
|
Name: "edge-app-demo",
|
||||||
AppVersion: "1.0.0",
|
AppVersion: "1.0.0",
|
||||||
Organization: "edp2",
|
Organization: "edp2",
|
||||||
},
|
},
|
||||||
Spec: Spec{
|
Spec: Spec{
|
||||||
|
|
@ -7,8 +7,6 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// EdgeConnectConfig represents the top-level configuration structure
|
// EdgeConnectConfig represents the top-level configuration structure
|
||||||
|
|
@ -100,75 +98,10 @@ func (c *EdgeConnectConfig) GetImagePath() string {
|
||||||
if c.Spec.IsDockerApp() && c.Spec.DockerApp.Image != "" {
|
if c.Spec.IsDockerApp() && c.Spec.DockerApp.Image != "" {
|
||||||
return c.Spec.DockerApp.Image
|
return c.Spec.DockerApp.Image
|
||||||
}
|
}
|
||||||
|
// Default for kubernetes apps
|
||||||
// For kubernetes apps, extract image from manifest
|
|
||||||
if c.Spec.IsK8sApp() && c.Spec.K8sApp.ManifestFile != "" {
|
|
||||||
if image, err := extractImageFromK8sManifest(c.Spec.K8sApp.ManifestFile); err == nil && image != "" {
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback default for kubernetes apps
|
|
||||||
return "https://registry-1.docker.io/library/nginx:latest"
|
return "https://registry-1.docker.io/library/nginx:latest"
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractImageFromK8sManifest extracts the container image from a Kubernetes manifest
|
|
||||||
func extractImageFromK8sManifest(manifestPath string) (string, error) {
|
|
||||||
data, err := os.ReadFile(manifestPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read manifest: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse multi-document YAML
|
|
||||||
decoder := yaml.NewDecoder(strings.NewReader(string(data)))
|
|
||||||
|
|
||||||
for {
|
|
||||||
var doc map[string]interface{}
|
|
||||||
if err := decoder.Decode(&doc); err != nil {
|
|
||||||
break // End of documents or error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a Deployment
|
|
||||||
kind, ok := doc["kind"].(string)
|
|
||||||
if !ok || kind != "Deployment" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to spec.template.spec.containers[0].image
|
|
||||||
spec, ok := doc["spec"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
template, ok := spec["template"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
templateSpec, ok := template["spec"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
containers, ok := templateSpec["containers"].([]interface{})
|
|
||||||
if !ok || len(containers) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
firstContainer, ok := containers[0].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
image, ok := firstContainer["image"].(string)
|
|
||||||
if ok && image != "" {
|
|
||||||
return image, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("no image found in Deployment manifest")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate validates metadata fields
|
// Validate validates metadata fields
|
||||||
func (m *Metadata) Validate() error {
|
func (m *Metadata) Validate() error {
|
||||||
if m.Name == "" {
|
if m.Name == "" {
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// ABOUTME: Authentication providers for EdgeXR Master Controller API
|
// ABOUTME: Authentication providers for EdgeXR Master Controller API
|
||||||
// ABOUTME: Supports Bearer token authentication with pluggable provider interface
|
// ABOUTME: Supports Bearer token authentication with pluggable provider interface
|
||||||
|
|
||||||
package edgeconnect
|
package edgeconnect_client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -139,7 +140,10 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = resp.Body.Close()
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
// Can't use c.logf here since this is in auth provider
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to close auth response body: %v\n", err)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Read response body - same as existing implementation
|
// Read response body - same as existing implementation
|
||||||
353
internal/infrastructure/edgeconnect_client/client.go
Normal file
353
internal/infrastructure/edgeconnect_client/client.go
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth
|
||||||
|
// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations
|
||||||
|
|
||||||
|
package edgeconnect_client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/infrastructure/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client represents the EdgeXR Master Controller SDK client
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
AuthProvider AuthProvider
|
||||||
|
RetryOpts transport.RetryOptions
|
||||||
|
Logger Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger interface for optional logging
|
||||||
|
type Logger interface {
|
||||||
|
Printf(format string, v ...interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRetryOptions returns sensible default retry configuration
|
||||||
|
func DefaultRetryOptions() transport.RetryOptions {
|
||||||
|
return transport.RetryOptions{
|
||||||
|
MaxRetries: 3,
|
||||||
|
InitialDelay: 1 * time.Second,
|
||||||
|
MaxDelay: 30 * time.Second,
|
||||||
|
Multiplier: 2.0,
|
||||||
|
RetryableHTTPStatusCodes: []int{
|
||||||
|
http.StatusRequestTimeout,
|
||||||
|
http.StatusTooManyRequests,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
http.StatusBadGateway,
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
http.StatusGatewayTimeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option represents a configuration option for the client
|
||||||
|
type Option func(*Client)
|
||||||
|
|
||||||
|
// WithHTTPClient sets a custom HTTP client
|
||||||
|
func WithHTTPClient(client *http.Client) Option {
|
||||||
|
return func(c *Client) {
|
||||||
|
c.HTTPClient = client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAuthProvider sets the authentication provider
|
||||||
|
func WithAuthProvider(auth AuthProvider) Option {
|
||||||
|
return func(c *Client) {
|
||||||
|
c.AuthProvider = auth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRetryOptions sets retry configuration
|
||||||
|
func WithRetryOptions(opts transport.RetryOptions) Option {
|
||||||
|
return func(c *Client) {
|
||||||
|
c.RetryOpts = opts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLogger sets a logger for debugging
|
||||||
|
func WithLogger(logger Logger) Option {
|
||||||
|
return func(c *Client) {
|
||||||
|
c.Logger = logger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new EdgeXR SDK client
|
||||||
|
func NewClient(baseURL string, options ...Option) *Client {
|
||||||
|
client := &Client{
|
||||||
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
AuthProvider: NewNoAuthProvider(),
|
||||||
|
RetryOpts: DefaultRetryOptions(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range options {
|
||||||
|
opt(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientWithCredentials creates a new EdgeXR SDK client with username/password authentication
|
||||||
|
func NewClientWithCredentials(baseURL, username, password string, options ...Option) *Client {
|
||||||
|
// Pass the HTTP client from options to the provider if it exists
|
||||||
|
tempClient := &Client{
|
||||||
|
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
for _, opt := range options {
|
||||||
|
opt(tempClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
HTTPClient: tempClient.HTTPClient,
|
||||||
|
AuthProvider: NewUsernamePasswordProvider(baseURL, username, password, tempClient.HTTPClient),
|
||||||
|
RetryOpts: DefaultRetryOptions(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply other options again, which might override the HTTPClient, but that's fine.
|
||||||
|
for _, opt := range options {
|
||||||
|
opt(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logf logs a message if a logger is configured
|
||||||
|
func (c *Client) Logf(format string, v ...interface{}) {
|
||||||
|
if c.Logger != nil {
|
||||||
|
c.Logger.Printf(format, v...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call performs a generic API call
|
||||||
|
func (c *Client) Call(ctx context.Context, method, path string, body, result interface{}) (*http.Response, error) {
|
||||||
|
t := c.getTransport()
|
||||||
|
url := c.BaseURL + path
|
||||||
|
|
||||||
|
resp, err := t.Call(ctx, method, url, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("API call to %s %s failed: %w", method, path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If result is nil, the caller doesn't expect a body to be parsed.
|
||||||
|
// They are responsible for closing the response body.
|
||||||
|
if result == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If result is not nil, we handle the body and closing it.
|
||||||
|
defer func() {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
c.Logf("Failed to close response body: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return resp, c.handleErrorResponse(resp, fmt.Sprintf("%s %s", method, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different response types
|
||||||
|
switch v := result.(type) {
|
||||||
|
case *string:
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
*v = string(bodyBytes)
|
||||||
|
case io.Writer:
|
||||||
|
_, err := io.Copy(v, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("failed to write response body: %w", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Default to JSON decoding
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
||||||
|
return resp, fmt.Errorf("failed to decode JSON response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTransport creates an HTTP transport with current client settings
|
||||||
|
func (c *Client) getTransport() *transport.Transport {
|
||||||
|
return transport.NewTransport(
|
||||||
|
c.RetryOpts,
|
||||||
|
c.AuthProvider,
|
||||||
|
c.Logger,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleErrorResponse creates an appropriate error from HTTP error response
|
||||||
|
func (c *Client) handleErrorResponse(resp *http.Response, operation string) error {
|
||||||
|
messages := []string{
|
||||||
|
fmt.Sprintf("%s failed with status %d", operation, resp.StatusCode),
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body) // Read body, ignore error as it might be empty
|
||||||
|
if len(bodyBytes) > 0 {
|
||||||
|
messages = append(messages, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Messages: messages,
|
||||||
|
Body: bodyBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStreamingResponse parses the EdgeXR streaming JSON response format
|
||||||
|
func (c *Client) ParseStreamingResponse(resp *http.Response, result interface{}) error {
|
||||||
|
var responses []Response[App]
|
||||||
|
|
||||||
|
parseErr := transport.ParseJSONLines(resp.Body, func(line []byte) error {
|
||||||
|
var response Response[App]
|
||||||
|
if err := json.Unmarshal(line, &response); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
responses = append(responses, response)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if parseErr != nil {
|
||||||
|
return parseErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract data from responses
|
||||||
|
var apps []App
|
||||||
|
var messages []string
|
||||||
|
|
||||||
|
for _, response := range responses {
|
||||||
|
if response.HasData() {
|
||||||
|
apps = append(apps, response.Data)
|
||||||
|
}
|
||||||
|
if response.IsMessage() {
|
||||||
|
messages = append(messages, response.Data.GetMessage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have error messages, return them
|
||||||
|
if len(messages) > 0 {
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Messages: messages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set result based on type
|
||||||
|
switch v := result.(type) {
|
||||||
|
case *[]App:
|
||||||
|
*v = apps
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported result type: %T", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for parsing streaming responses
|
||||||
|
|
||||||
|
// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances
|
||||||
|
func (c *Client) ParseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error {
|
||||||
|
var responses []Response[AppInstance]
|
||||||
|
|
||||||
|
parseErr := transport.ParseJSONLines(resp.Body, func(line []byte) error {
|
||||||
|
var response Response[AppInstance]
|
||||||
|
if err := json.Unmarshal(line, &response); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
responses = append(responses, response)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if parseErr != nil {
|
||||||
|
return parseErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract data from responses
|
||||||
|
var appInstances []AppInstance
|
||||||
|
var messages []string
|
||||||
|
|
||||||
|
for _, response := range responses {
|
||||||
|
if response.HasData() {
|
||||||
|
appInstances = append(appInstances, response.Data)
|
||||||
|
}
|
||||||
|
if response.IsMessage() {
|
||||||
|
messages = append(messages, response.Data.GetMessage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have error messages, return them
|
||||||
|
if len(messages) > 0 {
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Messages: messages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set result based on type
|
||||||
|
switch v := result.(type) {
|
||||||
|
case *[]AppInstance:
|
||||||
|
*v = appInstances
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported result type: %T", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStreamingCloudletResponse parses the EdgeXR streaming JSON response format for cloudlets
|
||||||
|
func (c *Client) ParseStreamingCloudletResponse(resp *http.Response, result interface{}) error {
|
||||||
|
var responses []Response[Cloudlet]
|
||||||
|
|
||||||
|
parseErr := transport.ParseJSONLines(resp.Body, func(line []byte) error {
|
||||||
|
var response Response[Cloudlet]
|
||||||
|
if err := json.Unmarshal(line, &response); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
responses = append(responses, response)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if parseErr != nil {
|
||||||
|
return parseErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract data from responses
|
||||||
|
var cloudlets []Cloudlet
|
||||||
|
var messages []string
|
||||||
|
|
||||||
|
for _, response := range responses {
|
||||||
|
if response.HasData() {
|
||||||
|
cloudlets = append(cloudlets, response.Data)
|
||||||
|
}
|
||||||
|
if response.IsMessage() {
|
||||||
|
messages = append(messages, response.Data.GetMessage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have error messages, return them
|
||||||
|
if len(messages) > 0 {
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Messages: messages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set result based on type
|
||||||
|
switch v := result.(type) {
|
||||||
|
case *[]Cloudlet:
|
||||||
|
*v = cloudlets
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported result type: %T", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
// ABOUTME: Core type definitions for EdgeXR Master Controller SDK
|
// ABOUTME: Core type definitions for EdgeXR Master Controller SDK
|
||||||
// ABOUTME: These types are based on the swagger API specification and existing client patterns
|
// ABOUTME: These types are based on the swagger API specification and existing client patterns
|
||||||
|
|
||||||
package edgeconnect
|
package edgeconnect_client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// App field constants for partial updates (based on EdgeXR API specification)
|
// App field constants for partial updates (based on EdgeXR API specification)
|
||||||
|
|
@ -60,74 +62,74 @@ const (
|
||||||
|
|
||||||
// AppInstance field constants for partial updates (based on EdgeXR API specification)
|
// AppInstance field constants for partial updates (based on EdgeXR API specification)
|
||||||
const (
|
const (
|
||||||
AppInstFieldKey = "2"
|
AppInstFieldKey = "2"
|
||||||
AppInstFieldKeyAppKey = "2.1"
|
AppInstFieldKeyAppKey = "2.1"
|
||||||
AppInstFieldKeyAppKeyOrganization = "2.1.1"
|
AppInstFieldKeyAppKeyOrganization = "2.1.1"
|
||||||
AppInstFieldKeyAppKeyName = "2.1.2"
|
AppInstFieldKeyAppKeyName = "2.1.2"
|
||||||
AppInstFieldKeyAppKeyVersion = "2.1.3"
|
AppInstFieldKeyAppKeyVersion = "2.1.3"
|
||||||
AppInstFieldKeyClusterInstKey = "2.4"
|
AppInstFieldKeyClusterInstKey = "2.4"
|
||||||
AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1"
|
AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1"
|
||||||
AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1"
|
AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1"
|
||||||
AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2"
|
AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2"
|
||||||
AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1"
|
AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1"
|
||||||
AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2"
|
AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2"
|
||||||
AppInstFieldKeyClusterInstKeyCloudletKeyFederatedOrganization = "2.4.2.3"
|
AppInstFieldKeyClusterInstKeyCloudletKeyFederatedOrganization = "2.4.2.3"
|
||||||
AppInstFieldKeyClusterInstKeyOrganization = "2.4.3"
|
AppInstFieldKeyClusterInstKeyOrganization = "2.4.3"
|
||||||
AppInstFieldCloudletLoc = "3"
|
AppInstFieldCloudletLoc = "3"
|
||||||
AppInstFieldCloudletLocLatitude = "3.1"
|
AppInstFieldCloudletLocLatitude = "3.1"
|
||||||
AppInstFieldCloudletLocLongitude = "3.2"
|
AppInstFieldCloudletLocLongitude = "3.2"
|
||||||
AppInstFieldCloudletLocHorizontalAccuracy = "3.3"
|
AppInstFieldCloudletLocHorizontalAccuracy = "3.3"
|
||||||
AppInstFieldCloudletLocVerticalAccuracy = "3.4"
|
AppInstFieldCloudletLocVerticalAccuracy = "3.4"
|
||||||
AppInstFieldCloudletLocAltitude = "3.5"
|
AppInstFieldCloudletLocAltitude = "3.5"
|
||||||
AppInstFieldCloudletLocCourse = "3.6"
|
AppInstFieldCloudletLocCourse = "3.6"
|
||||||
AppInstFieldCloudletLocSpeed = "3.7"
|
AppInstFieldCloudletLocSpeed = "3.7"
|
||||||
AppInstFieldCloudletLocTimestamp = "3.8"
|
AppInstFieldCloudletLocTimestamp = "3.8"
|
||||||
AppInstFieldCloudletLocTimestampSeconds = "3.8.1"
|
AppInstFieldCloudletLocTimestampSeconds = "3.8.1"
|
||||||
AppInstFieldCloudletLocTimestampNanos = "3.8.2"
|
AppInstFieldCloudletLocTimestampNanos = "3.8.2"
|
||||||
AppInstFieldUri = "4"
|
AppInstFieldUri = "4"
|
||||||
AppInstFieldLiveness = "6"
|
AppInstFieldLiveness = "6"
|
||||||
AppInstFieldMappedPorts = "9"
|
AppInstFieldMappedPorts = "9"
|
||||||
AppInstFieldMappedPortsProto = "9.1"
|
AppInstFieldMappedPortsProto = "9.1"
|
||||||
AppInstFieldMappedPortsInternalPort = "9.2"
|
AppInstFieldMappedPortsInternalPort = "9.2"
|
||||||
AppInstFieldMappedPortsPublicPort = "9.3"
|
AppInstFieldMappedPortsPublicPort = "9.3"
|
||||||
AppInstFieldMappedPortsFqdnPrefix = "9.5"
|
AppInstFieldMappedPortsFqdnPrefix = "9.5"
|
||||||
AppInstFieldMappedPortsEndPort = "9.6"
|
AppInstFieldMappedPortsEndPort = "9.6"
|
||||||
AppInstFieldMappedPortsTls = "9.7"
|
AppInstFieldMappedPortsTls = "9.7"
|
||||||
AppInstFieldMappedPortsNginx = "9.8"
|
AppInstFieldMappedPortsNginx = "9.8"
|
||||||
AppInstFieldMappedPortsMaxPktSize = "9.9"
|
AppInstFieldMappedPortsMaxPktSize = "9.9"
|
||||||
AppInstFieldFlavor = "12"
|
AppInstFieldFlavor = "12"
|
||||||
AppInstFieldFlavorName = "12.1"
|
AppInstFieldFlavorName = "12.1"
|
||||||
AppInstFieldState = "14"
|
AppInstFieldState = "14"
|
||||||
AppInstFieldErrors = "15"
|
AppInstFieldErrors = "15"
|
||||||
AppInstFieldCrmOverride = "16"
|
AppInstFieldCrmOverride = "16"
|
||||||
AppInstFieldRuntimeInfo = "17"
|
AppInstFieldRuntimeInfo = "17"
|
||||||
AppInstFieldRuntimeInfoContainerIds = "17.1"
|
AppInstFieldRuntimeInfoContainerIds = "17.1"
|
||||||
AppInstFieldCreatedAt = "21"
|
AppInstFieldCreatedAt = "21"
|
||||||
AppInstFieldCreatedAtSeconds = "21.1"
|
AppInstFieldCreatedAtSeconds = "21.1"
|
||||||
AppInstFieldCreatedAtNanos = "21.2"
|
AppInstFieldCreatedAtNanos = "21.2"
|
||||||
AppInstFieldAutoClusterIpAccess = "22"
|
AppInstFieldAutoClusterIpAccess = "22"
|
||||||
AppInstFieldRevision = "24"
|
AppInstFieldRevision = "24"
|
||||||
AppInstFieldForceUpdate = "25"
|
AppInstFieldForceUpdate = "25"
|
||||||
AppInstFieldUpdateMultiple = "26"
|
AppInstFieldUpdateMultiple = "26"
|
||||||
AppInstFieldConfigs = "27"
|
AppInstFieldConfigs = "27"
|
||||||
AppInstFieldConfigsKind = "27.1"
|
AppInstFieldConfigsKind = "27.1"
|
||||||
AppInstFieldConfigsConfig = "27.2"
|
AppInstFieldConfigsConfig = "27.2"
|
||||||
AppInstFieldHealthCheck = "29"
|
AppInstFieldHealthCheck = "29"
|
||||||
AppInstFieldPowerState = "31"
|
AppInstFieldPowerState = "31"
|
||||||
AppInstFieldExternalVolumeSize = "32"
|
AppInstFieldExternalVolumeSize = "32"
|
||||||
AppInstFieldAvailabilityZone = "33"
|
AppInstFieldAvailabilityZone = "33"
|
||||||
AppInstFieldVmFlavor = "34"
|
AppInstFieldVmFlavor = "34"
|
||||||
AppInstFieldOptRes = "35"
|
AppInstFieldOptRes = "35"
|
||||||
AppInstFieldUpdatedAt = "36"
|
AppInstFieldUpdatedAt = "36"
|
||||||
AppInstFieldUpdatedAtSeconds = "36.1"
|
AppInstFieldUpdatedAtSeconds = "36.1"
|
||||||
AppInstFieldUpdatedAtNanos = "36.2"
|
AppInstFieldUpdatedAtNanos = "36.2"
|
||||||
AppInstFieldRealClusterName = "37"
|
AppInstFieldRealClusterName = "37"
|
||||||
AppInstFieldInternalPortToLbIp = "38"
|
AppInstFieldInternalPortToLbIp = "38"
|
||||||
AppInstFieldInternalPortToLbIpKey = "38.1"
|
AppInstFieldInternalPortToLbIpKey = "38.1"
|
||||||
AppInstFieldInternalPortToLbIpValue = "38.2"
|
AppInstFieldInternalPortToLbIpValue = "38.2"
|
||||||
AppInstFieldDedicatedIp = "39"
|
AppInstFieldDedicatedIp = "39"
|
||||||
AppInstFieldUniqueId = "40"
|
AppInstFieldUniqueId = "40"
|
||||||
AppInstFieldDnsLabel = "41"
|
AppInstFieldDnsLabel = "41"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Message interface for types that can provide error messages
|
// Message interface for types that can provide error messages
|
||||||
|
|
@ -271,26 +273,6 @@ func (res *Response[T]) IsMessage() bool {
|
||||||
return res.Data.GetMessage() != ""
|
return res.Data.GetMessage() != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResultResponse represents an API result with error code
|
|
||||||
type ResultResponse struct {
|
|
||||||
Result struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
Code int `json:"code"`
|
|
||||||
} `json:"result"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ResultResponse) IsError() bool {
|
|
||||||
return r.Result.Code >= 400
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ResultResponse) GetMessage() string {
|
|
||||||
return r.Result.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ResultResponse) GetCode() int {
|
|
||||||
return r.Result.Code
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responses wraps multiple API responses with metadata
|
// Responses wraps multiple API responses with metadata
|
||||||
type Responses[T Message] struct {
|
type Responses[T Message] struct {
|
||||||
Responses []Response[T] `json:"responses,omitempty"`
|
Responses []Response[T] `json:"responses,omitempty"`
|
||||||
|
|
@ -378,4 +360,4 @@ type CloudletResourceUsage struct {
|
||||||
CloudletKey CloudletKey `json:"cloudlet_key"`
|
CloudletKey CloudletKey `json:"cloudlet_key"`
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
Usage map[string]interface{} `json:"usage"`
|
Usage map[string]interface{} `json:"usage"`
|
||||||
}
|
}
|
||||||
28
internal/infrastructure/transport/parser.go
Normal file
28
internal/infrastructure/transport/parser.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseJSONLines parses streaming JSON response line by line
|
||||||
|
func ParseJSONLines(body io.Reader, callback func([]byte) error) error {
|
||||||
|
decoder := json.NewDecoder(body)
|
||||||
|
|
||||||
|
for {
|
||||||
|
var raw json.RawMessage
|
||||||
|
if err := decoder.Decode(&raw); err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to decode JSON line: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := callback(raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
137
internal/infrastructure/transport/transport.go
Normal file
137
internal/infrastructure/transport/transport.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthProvider defines the interface for attaching authentication to requests.
|
||||||
|
type AuthProvider interface {
|
||||||
|
Attach(ctx context.Context, req *http.Request) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger defines the interface for logging.
|
||||||
|
type Logger interface {
|
||||||
|
Printf(format string, v ...interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryOptions configures the retry behavior for API calls.
|
||||||
|
type RetryOptions struct {
|
||||||
|
MaxRetries int
|
||||||
|
InitialDelay time.Duration
|
||||||
|
MaxDelay time.Duration
|
||||||
|
Multiplier float64
|
||||||
|
RetryableHTTPStatusCodes []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transport handles the lifecycle of an HTTP request, including authentication and retries.
|
||||||
|
type Transport struct {
|
||||||
|
retryOptions RetryOptions
|
||||||
|
authProvider AuthProvider
|
||||||
|
logger Logger
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTransport creates a new Transport.
|
||||||
|
func NewTransport(retryOptions RetryOptions, authProvider AuthProvider, logger Logger) *Transport {
|
||||||
|
return &Transport{
|
||||||
|
retryOptions: retryOptions,
|
||||||
|
authProvider: authProvider,
|
||||||
|
logger: logger,
|
||||||
|
httpClient: &http.Client{}, // Use a default client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call executes an HTTP request with the configured transport options.
|
||||||
|
func (t *Transport) Call(ctx context.Context, method, url string, body interface{}) (*http.Response, error) {
|
||||||
|
var reqBody io.Reader
|
||||||
|
if body != nil {
|
||||||
|
// Marshal body to JSON
|
||||||
|
jsonData, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
reqBody = bytes.NewBuffer(jsonData)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp *http.Response
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for i := 0; i <= t.retryOptions.MaxRetries; i++ {
|
||||||
|
// Create a new request for each attempt
|
||||||
|
req, reqErr := http.NewRequestWithContext(ctx, method, url, reqBody)
|
||||||
|
if reqErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", reqErr)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Attach authentication
|
||||||
|
if authErr := t.authProvider.Attach(ctx, req); authErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to attach authentication: %w", authErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the request
|
||||||
|
resp, err = t.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.logf("Request failed (attempt %d): %v", i+1, err)
|
||||||
|
// Decide if we should retry based on the error (e.g., network errors)
|
||||||
|
if i < t.retryOptions.MaxRetries {
|
||||||
|
time.Sleep(t.calculateBackoff(i))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("request failed after %d attempts: %w", t.retryOptions.MaxRetries+1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should retry based on status code
|
||||||
|
if t.isRetryable(resp.StatusCode) && i < t.retryOptions.MaxRetries {
|
||||||
|
t.logf("Request returned retryable status %d (attempt %d)", resp.StatusCode, i+1)
|
||||||
|
// We need to close the body before retrying
|
||||||
|
if resp.Body != nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
time.Sleep(t.calculateBackoff(i))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not retryable, break the loop
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRetryable checks if an HTTP status code is in the list of retryable codes.
|
||||||
|
func (t *Transport) isRetryable(statusCode int) bool {
|
||||||
|
for _, code := range t.retryOptions.RetryableHTTPStatusCodes {
|
||||||
|
if statusCode == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateBackoff computes the delay for the next retry attempt.
|
||||||
|
func (t *Transport) calculateBackoff(attempt int) time.Duration {
|
||||||
|
if attempt == 0 {
|
||||||
|
return t.retryOptions.InitialDelay
|
||||||
|
}
|
||||||
|
delay := float64(t.retryOptions.InitialDelay) * math.Pow(t.retryOptions.Multiplier, float64(attempt))
|
||||||
|
if delay > float64(t.retryOptions.MaxDelay) {
|
||||||
|
return t.retryOptions.MaxDelay
|
||||||
|
}
|
||||||
|
return time.Duration(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logf logs a message if a logger is configured.
|
||||||
|
func (t *Transport) logf(format string, v ...interface{}) {
|
||||||
|
if t.logger != nil {
|
||||||
|
t.logger.Printf(format, v...)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
internal/ports/driven/app_repository.go
Normal file
14
internal/ports/driven/app_repository.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package driven
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppRepository interface {
|
||||||
|
CreateApp(ctx context.Context, region string, app *domain.App) error
|
||||||
|
ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error)
|
||||||
|
ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error)
|
||||||
|
DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error
|
||||||
|
UpdateApp(ctx context.Context, region string, app *domain.App) error
|
||||||
|
}
|
||||||
13
internal/ports/driven/cloudlet_repository.go
Normal file
13
internal/ports/driven/cloudlet_repository.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package driven
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CloudletRepository interface {
|
||||||
|
CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error
|
||||||
|
ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error)
|
||||||
|
ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error)
|
||||||
|
DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error
|
||||||
|
}
|
||||||
15
internal/ports/driven/instance_repository.go
Normal file
15
internal/ports/driven/instance_repository.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package driven
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppInstanceRepository interface {
|
||||||
|
CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error
|
||||||
|
ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error)
|
||||||
|
ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error)
|
||||||
|
DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error
|
||||||
|
UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error
|
||||||
|
RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error
|
||||||
|
}
|
||||||
20
internal/ports/driven/organization_repository.go
Normal file
20
internal/ports/driven/organization_repository.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package driven
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrganizationRepository defines the port for interacting with organization data storage.
|
||||||
|
// This interface provides a technology-agnostic way for the core application to manage organizations.
|
||||||
|
type OrganizationRepository interface {
|
||||||
|
// CreateOrganization persists a new organization.
|
||||||
|
CreateOrganization(ctx context.Context, org *domain.Organization) error
|
||||||
|
// ShowOrganization retrieves a single organization by its name.
|
||||||
|
ShowOrganization(ctx context.Context, name string) (*domain.Organization, error)
|
||||||
|
// UpdateOrganization updates an existing organization.
|
||||||
|
UpdateOrganization(ctx context.Context, org *domain.Organization) error
|
||||||
|
// DeleteOrganization removes an organization by its name.
|
||||||
|
DeleteOrganization(ctx context.Context, name string) error
|
||||||
|
}
|
||||||
14
internal/ports/driving/app_service.go
Normal file
14
internal/ports/driving/app_service.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package driving
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppService interface {
|
||||||
|
CreateApp(ctx context.Context, region string, app *domain.App) error
|
||||||
|
ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error)
|
||||||
|
ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error)
|
||||||
|
DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error
|
||||||
|
UpdateApp(ctx context.Context, region string, app *domain.App) error
|
||||||
|
}
|
||||||
13
internal/ports/driving/cloudlet_service.go
Normal file
13
internal/ports/driving/cloudlet_service.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package driving
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CloudletService interface {
|
||||||
|
CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error
|
||||||
|
ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error)
|
||||||
|
ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error)
|
||||||
|
DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error
|
||||||
|
}
|
||||||
15
internal/ports/driving/instance_service.go
Normal file
15
internal/ports/driving/instance_service.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package driving
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppInstanceService interface {
|
||||||
|
CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error
|
||||||
|
ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error)
|
||||||
|
ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error)
|
||||||
|
DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error
|
||||||
|
UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error
|
||||||
|
RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error
|
||||||
|
}
|
||||||
16
internal/ports/driving/organization_service.go
Normal file
16
internal/ports/driving/organization_service.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package driving
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrganizationService defines the driving port for managing organizations.
|
||||||
|
// This is the primary interface for interacting with the application's organization logic.
|
||||||
|
type OrganizationService interface {
|
||||||
|
Create(ctx context.Context, org *domain.Organization) error
|
||||||
|
Get(ctx context.Context, name string) (*domain.Organization, error)
|
||||||
|
Update(ctx context.Context, org *domain.Organization) error
|
||||||
|
Delete(ctx context.Context, name string) error
|
||||||
|
}
|
||||||
7
main.go
7
main.go
|
|
@ -1,7 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/cmd"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cmd.Execute()
|
|
||||||
}
|
|
||||||
BIN
public.gpg
BIN
public.gpg
Binary file not shown.
|
|
@ -16,18 +16,18 @@ A comprehensive Go SDK for the EdgeXR Master Controller API, providing typed int
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
import "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Username/password (recommended)
|
// Username/password (recommended)
|
||||||
client := v2.NewClientWithCredentials(baseURL, username, password)
|
client := client.NewClientWithCredentials(baseURL, username, password)
|
||||||
|
|
||||||
// Static Bearer token
|
// Static Bearer token
|
||||||
client := v2.NewClient(baseURL,
|
client := client.NewClient(baseURL,
|
||||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)))
|
client.WithAuthProvider(client.NewStaticTokenProvider(token)))
|
||||||
```
|
```
|
||||||
|
|
||||||
### Basic Usage
|
### Basic Usage
|
||||||
|
|
@ -36,10 +36,10 @@ client := v2.NewClient(baseURL,
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create an application
|
// Create an application
|
||||||
app := &v2.NewAppInput{
|
app := &client.NewAppInput{
|
||||||
Region: "us-west",
|
Region: "us-west",
|
||||||
App: v2.App{
|
App: client.App{
|
||||||
Key: v2.AppKey{
|
Key: client.AppKey{
|
||||||
Organization: "myorg",
|
Organization: "myorg",
|
||||||
Name: "my-app",
|
Name: "my-app",
|
||||||
Version: "1.0.0",
|
Version: "1.0.0",
|
||||||
|
|
@ -49,28 +49,28 @@ app := &v2.NewAppInput{
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := v2.CreateApp(ctx, app); err != nil {
|
if err := client.CreateApp(ctx, app); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deploy an application instance
|
// Deploy an application instance
|
||||||
instance := &v2.NewAppInstanceInput{
|
instance := &client.NewAppInstanceInput{
|
||||||
Region: "us-west",
|
Region: "us-west",
|
||||||
AppInst: v2.AppInstance{
|
AppInst: client.AppInstance{
|
||||||
Key: v2.AppInstanceKey{
|
Key: client.AppInstanceKey{
|
||||||
Organization: "myorg",
|
Organization: "myorg",
|
||||||
Name: "my-instance",
|
Name: "my-instance",
|
||||||
CloudletKey: v2.CloudletKey{
|
CloudletKey: client.CloudletKey{
|
||||||
Organization: "cloudlet-provider",
|
Organization: "cloudlet-provider",
|
||||||
Name: "edge-cloudlet",
|
Name: "edge-cloudlet",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AppKey: app.App.Key,
|
AppKey: app.App.Key,
|
||||||
Flavor: v2.Flavor{Name: "m4.small"},
|
Flavor: client.Flavor{Name: "m4.small"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := v2.CreateAppInstance(ctx, instance); err != nil {
|
if err := client.CreateAppInstance(ctx, instance); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -101,22 +101,22 @@ if err := v2.CreateAppInstance(ctx, instance); err != nil {
|
||||||
## Configuration Options
|
## Configuration Options
|
||||||
|
|
||||||
```go
|
```go
|
||||||
client := v2.NewClient(baseURL,
|
client := client.NewClient(baseURL,
|
||||||
// Custom HTTP client with timeout
|
// Custom HTTP client with timeout
|
||||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||||
|
|
||||||
// Authentication provider
|
// Authentication provider
|
||||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)),
|
client.WithAuthProvider(client.NewStaticTokenProvider(token)),
|
||||||
|
|
||||||
// Retry configuration
|
// Retry configuration
|
||||||
v2.WithRetryOptions(v2.RetryOptions{
|
client.WithRetryOptions(client.RetryOptions{
|
||||||
MaxRetries: 5,
|
MaxRetries: 5,
|
||||||
InitialDelay: 1 * time.Second,
|
InitialDelay: 1 * time.Second,
|
||||||
MaxDelay: 30 * time.Second,
|
MaxDelay: 30 * time.Second,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Request logging
|
// Request logging
|
||||||
v2.WithLogger(log.Default()),
|
client.WithLogger(log.Default()),
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -125,14 +125,14 @@ client := v2.NewClient(baseURL,
|
||||||
### Simple App Deployment
|
### Simple App Deployment
|
||||||
```bash
|
```bash
|
||||||
# Run basic example
|
# Run basic example
|
||||||
EDGEXR_USERNAME=user EDGEXR_PASSWORD=pass go run sdk/examples/deploy_app.go
|
EDGE_CONNECT_USERNAME=user EDGE_CONNECT_PASSWORD=pass go run sdk/examples/deploy_app.go
|
||||||
```
|
```
|
||||||
|
|
||||||
### Comprehensive Workflow
|
### Comprehensive Workflow
|
||||||
```bash
|
```bash
|
||||||
# Run full workflow demonstration
|
# Run full workflow demonstration
|
||||||
cd sdk/examples/comprehensive
|
cd sdk/examples/comprehensive
|
||||||
EDGEXR_USERNAME=user EDGEXR_PASSWORD=pass go run main.go
|
EDGE_CONNECT_USERNAME=user EDGE_CONNECT_PASSWORD=pass go run main.go
|
||||||
```
|
```
|
||||||
|
|
||||||
## Authentication Methods
|
## Authentication Methods
|
||||||
|
|
@ -141,7 +141,7 @@ EDGEXR_USERNAME=user EDGEXR_PASSWORD=pass go run main.go
|
||||||
Uses the existing `/api/v1/login` endpoint with automatic token caching:
|
Uses the existing `/api/v1/login` endpoint with automatic token caching:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
client := v2.NewClientWithCredentials(baseURL, username, password)
|
client := client.NewClientWithCredentials(baseURL, username, password)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
@ -154,23 +154,23 @@ client := v2.NewClientWithCredentials(baseURL, username, password)
|
||||||
For pre-obtained tokens:
|
For pre-obtained tokens:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
client := v2.NewClient(baseURL,
|
client := client.NewClient(baseURL,
|
||||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)))
|
client.WithAuthProvider(client.NewStaticTokenProvider(token)))
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
```go
|
```go
|
||||||
app, err := v2.ShowApp(ctx, appKey, region)
|
app, err := client.ShowApp(ctx, appKey, region)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check for specific error types
|
// Check for specific error types
|
||||||
if errors.Is(err, v2.ErrResourceNotFound) {
|
if errors.Is(err, client.ErrResourceNotFound) {
|
||||||
fmt.Println("App not found")
|
fmt.Println("App not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for API errors
|
// Check for API errors
|
||||||
var apiErr *v2.APIError
|
var apiErr *client.APIError
|
||||||
if errors.As(err, &apiErr) {
|
if errors.As(err, &apiErr) {
|
||||||
fmt.Printf("API Error %d: %s\n", apiErr.StatusCode, apiErr.Messages[0])
|
fmt.Printf("API Error %d: %s\n", apiErr.StatusCode, apiErr.Messages[0])
|
||||||
return
|
return
|
||||||
|
|
@ -213,13 +213,13 @@ The SDK provides a drop-in replacement with enhanced features:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Old approach
|
// Old approach
|
||||||
oldClient := &v2.EdgeConnect{
|
oldClient := &client.EdgeConnect{
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
Credentials: v2.Credentials{Username: user, Password: pass},
|
Credentials: client.Credentials{Username: user, Password: pass},
|
||||||
}
|
}
|
||||||
|
|
||||||
// New SDK approach
|
// New SDK approach
|
||||||
newClient := v2.NewClientWithCredentials(baseURL, user, pass)
|
newClient := client.NewClientWithCredentials(baseURL, user, pass)
|
||||||
|
|
||||||
// Same method calls, enhanced reliability
|
// Same method calls, enhanced reliability
|
||||||
err := newClient.CreateApp(ctx, input)
|
err := newClient.CreateApp(ctx, input)
|
||||||
|
|
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
// ABOUTME: Application instance lifecycle management APIs for EdgeXR Master Controller
|
|
||||||
// ABOUTME: Provides typed methods for creating, querying, and deleting application instances
|
|
||||||
|
|
||||||
package edgeconnect
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateAppInstance creates a new application instance in the specified region
|
|
||||||
// Maps to POST /auth/ctrl/CreateAppInst
|
|
||||||
func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInput) error {
|
|
||||||
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/CreateAppInst"
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("CreateAppInstance failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return c.handleErrorResponse(resp, "CreateAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse streaming JSON response
|
|
||||||
var appInstances []AppInstance
|
|
||||||
if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil {
|
|
||||||
return fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("CreateAppInstance: %s/%s created successfully",
|
|
||||||
input.AppInst.Key.Organization, input.AppInst.Key.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowAppInstance retrieves a single application instance by key and region
|
|
||||||
// Maps to POST /auth/ctrl/ShowAppInst
|
|
||||||
func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) (AppInstance, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
|
|
||||||
|
|
||||||
filter := AppInstanceFilter{
|
|
||||||
AppInstance: AppInstance{AppKey: appKey, Key: appInstKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return AppInstance{}, fmt.Errorf("app instance %s/%s: %w",
|
|
||||||
appInstKey.Organization, appInstKey.Name, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return AppInstance{}, c.handleErrorResponse(resp, "ShowAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse streaming JSON response
|
|
||||||
var appInstances []AppInstance
|
|
||||||
if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil {
|
|
||||||
return AppInstance{}, fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(appInstances) == 0 {
|
|
||||||
return AppInstance{}, fmt.Errorf("app instance %s/%s in region %s: %w",
|
|
||||||
appInstKey.Organization, appInstKey.Name, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return appInstances[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowAppInstances retrieves all application instances matching the filter criteria
|
|
||||||
// Maps to POST /auth/ctrl/ShowAppInst
|
|
||||||
func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) ([]AppInstance, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
|
|
||||||
|
|
||||||
filter := AppInstanceFilter{
|
|
||||||
AppInstance: AppInstance{Key: appInstKey, AppKey: appKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
|
||||||
return nil, c.handleErrorResponse(resp, "ShowAppInstances")
|
|
||||||
}
|
|
||||||
|
|
||||||
var appInstances []AppInstance
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return appInstances, nil // Return empty slice for not found
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil {
|
|
||||||
return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances))
|
|
||||||
return appInstances, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateAppInstance updates an application instance and then refreshes it
|
|
||||||
// Maps to POST /auth/ctrl/UpdateAppInst
|
|
||||||
func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstanceInput) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/UpdateAppInst"
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("UpdateAppInstance failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return c.handleErrorResponse(resp, "UpdateAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("UpdateAppInstance: %s/%s updated successfully",
|
|
||||||
input.AppInst.Key.Organization, input.AppInst.Key.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshAppInstance refreshes an application instance's state
|
|
||||||
// Maps to POST /auth/ctrl/RefreshAppInst
|
|
||||||
func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/RefreshAppInst"
|
|
||||||
|
|
||||||
filter := AppInstanceFilter{
|
|
||||||
AppInstance: AppInstance{Key: appInstKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("RefreshAppInstance failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return c.handleErrorResponse(resp, "RefreshAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("RefreshAppInstance: %s/%s refreshed successfully",
|
|
||||||
appInstKey.Organization, appInstKey.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteAppInstance removes an application instance from the specified region
|
|
||||||
// Maps to POST /auth/ctrl/DeleteAppInst
|
|
||||||
func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst"
|
|
||||||
|
|
||||||
filter := AppInstanceFilter{
|
|
||||||
AppInstance: AppInstance{Key: appInstKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("DeleteAppInstance failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 404 is acceptable for delete operations (already deleted)
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
|
||||||
return c.handleErrorResponse(resp, "DeleteAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("DeleteAppInstance: %s/%s deleted successfully",
|
|
||||||
appInstKey.Organization, appInstKey.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances
|
|
||||||
func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error {
|
|
||||||
var appInstances []AppInstance
|
|
||||||
var messages []string
|
|
||||||
var hasError bool
|
|
||||||
var errorCode int
|
|
||||||
var errorMessage string
|
|
||||||
|
|
||||||
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
|
|
||||||
// On permission denied, Edge API returns just an empty array []!
|
|
||||||
if len(line) == 0 || line[0] == '[' {
|
|
||||||
return fmt.Errorf("%w", ErrFaultyResponsePerhaps403)
|
|
||||||
}
|
|
||||||
// Try parsing as ResultResponse first (error format)
|
|
||||||
var resultResp ResultResponse
|
|
||||||
if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" {
|
|
||||||
if resultResp.IsError() {
|
|
||||||
hasError = true
|
|
||||||
errorCode = resultResp.GetCode()
|
|
||||||
errorMessage = resultResp.GetMessage()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try parsing as Response[AppInstance]
|
|
||||||
var response Response[AppInstance]
|
|
||||||
if err := json.Unmarshal(line, &response); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if response.HasData() {
|
|
||||||
appInstances = append(appInstances, response.Data)
|
|
||||||
}
|
|
||||||
if response.IsMessage() {
|
|
||||||
msg := response.Data.GetMessage()
|
|
||||||
messages = append(messages, msg)
|
|
||||||
// Check for error indicators in messages
|
|
||||||
if msg == "CreateError" || msg == "UpdateError" || msg == "DeleteError" {
|
|
||||||
hasError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if parseErr != nil {
|
|
||||||
return parseErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we detected an error, return it
|
|
||||||
if hasError {
|
|
||||||
apiErr := &APIError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Messages: messages,
|
|
||||||
}
|
|
||||||
if errorCode > 0 {
|
|
||||||
apiErr.StatusCode = errorCode
|
|
||||||
apiErr.Code = fmt.Sprintf("%d", errorCode)
|
|
||||||
}
|
|
||||||
if errorMessage != "" {
|
|
||||||
apiErr.Messages = append([]string{errorMessage}, apiErr.Messages...)
|
|
||||||
}
|
|
||||||
return apiErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set result based on type
|
|
||||||
switch v := result.(type) {
|
|
||||||
case *[]AppInstance:
|
|
||||||
*v = appInstances
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported result type: %T", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,527 +0,0 @@
|
||||||
// ABOUTME: Unit tests for AppInstance management APIs using httptest mock server
|
|
||||||
// ABOUTME: Tests create, show, list, refresh, and delete operations with error conditions
|
|
||||||
|
|
||||||
package edgeconnect
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateAppInstance(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input *NewAppInstanceInput
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
errorContains string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful creation",
|
|
||||||
input: &NewAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Flavor: Flavor{Name: "m4.small"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"message": "success"}`,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "validation error",
|
|
||||||
input: &NewAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "",
|
|
||||||
Name: "testinst",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 400,
|
|
||||||
mockResponse: `{"message": "organization is required"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "HTTP 200 with CreateError message",
|
|
||||||
input: &NewAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Flavor: Flavor{Name: "m4.small"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"data":{"message":"Creating"}}
|
|
||||||
{"data":{"message":"a service has been configured"}}
|
|
||||||
{"data":{"message":"CreateError"}}
|
|
||||||
{"data":{"message":"Deleting AppInst due to failure"}}
|
|
||||||
{"data":{"message":"Deleted AppInst successfully"}}`,
|
|
||||||
expectError: true,
|
|
||||||
errorContains: "CreateError",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "HTTP 200 with result error code",
|
|
||||||
input: &NewAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Flavor: Flavor{Name: "m4.small"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"data":{"message":"Creating"}}
|
|
||||||
{"data":{"message":"a service has been configured"}}
|
|
||||||
{"data":{"message":"CreateError"}}
|
|
||||||
{"data":{"message":"Deleting AppInst due to failure"}}
|
|
||||||
{"data":{"message":"Deleted AppInst successfully"}}
|
|
||||||
{"result":{"message":"Encountered failures: Create App Inst failed: deployments.apps is forbidden: User \"system:serviceaccount:edgexr:crm-telekomop-munich\" cannot create resource \"deployments\" in API group \"apps\" in the namespace \"gitea\"","code":400}}`,
|
|
||||||
expectError: true,
|
|
||||||
errorContains: "deployments.apps is forbidden",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/CreateAppInst", r.URL.Path)
|
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
err := client.CreateAppInstance(ctx, tt.input)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.errorContains != "" {
|
|
||||||
assert.Contains(t, err.Error(), tt.errorContains)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShowAppInstance(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
appKey AppKey
|
|
||||||
appInstKey AppInstanceKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
expectNotFound bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful show",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
appKey: AppKey{Name: "test-app-id"},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}}
|
|
||||||
`,
|
|
||||||
expectError: false,
|
|
||||||
expectNotFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "instance not found",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
appKey: AppKey{Name: "test-app-id"},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: "",
|
|
||||||
expectError: true,
|
|
||||||
expectNotFound: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
if tt.mockResponse != "" {
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.expectNotFound {
|
|
||||||
assert.Contains(t, err.Error(), "resource not found")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.appInstKey.Organization, appInst.Key.Organization)
|
|
||||||
assert.Equal(t, tt.appInstKey.Name, appInst.Key.Name)
|
|
||||||
assert.Equal(t, "Ready", appInst.State)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShowAppInstances(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path)
|
|
||||||
|
|
||||||
// Verify request body
|
|
||||||
var filter AppInstanceFilter
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&filter)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "testorg", filter.AppInstance.Key.Organization)
|
|
||||||
assert.Equal(t, "us-west", filter.Region)
|
|
||||||
|
|
||||||
// Return multiple app instances
|
|
||||||
response := `{"data": {"key": {"organization": "testorg", "name": "inst1"}, "state": "Ready"}}
|
|
||||||
{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}}
|
|
||||||
`
|
|
||||||
w.WriteHeader(200)
|
|
||||||
_, _ = w.Write([]byte(response))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, appInstances, 2)
|
|
||||||
assert.Equal(t, "inst1", appInstances[0].Key.Name)
|
|
||||||
assert.Equal(t, "Ready", appInstances[0].State)
|
|
||||||
assert.Equal(t, "inst2", appInstances[1].Key.Name)
|
|
||||||
assert.Equal(t, "Creating", appInstances[1].State)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateAppInstance(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input *UpdateAppInstanceInput
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful update",
|
|
||||||
input: &UpdateAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Flavor: Flavor{Name: "m4.medium"},
|
|
||||||
PowerState: "PowerOn",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"message": "success"}`,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "validation error",
|
|
||||||
input: &UpdateAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 400,
|
|
||||||
mockResponse: `{"message": "organization is required"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "instance not found",
|
|
||||||
input: &UpdateAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: `{"message": "app instance not found"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/UpdateAppInst", r.URL.Path)
|
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
||||||
|
|
||||||
// Verify request body
|
|
||||||
var input UpdateAppInstanceInput
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.input.Region, input.Region)
|
|
||||||
assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
err := client.UpdateAppInstance(ctx, tt.input)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshAppInstance(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
appInstKey AppInstanceKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful refresh",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "server error",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 500,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/RefreshAppInst", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := client.RefreshAppInstance(ctx, tt.appInstKey, tt.region)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteAppInstance(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
appInstKey AppInstanceKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful deletion",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "already deleted (404 ok)",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "server error",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 500,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/DeleteAppInst", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := client.DeleteAppInstance(ctx, tt.appInstKey, tt.region)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
// ABOUTME: Application lifecycle management APIs for EdgeXR Master Controller
|
|
||||||
// ABOUTME: Provides typed methods for creating, querying, and deleting applications
|
|
||||||
|
|
||||||
package edgeconnect
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// ErrResourceNotFound indicates the requested resource was not found
|
|
||||||
ErrResourceNotFound = fmt.Errorf("resource not found")
|
|
||||||
ErrFaultyResponsePerhaps403 = fmt.Errorf("faulty response from API, may indicate permission denied")
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateApp creates a new application in the specified region
|
|
||||||
// Maps to POST /auth/ctrl/CreateApp
|
|
||||||
func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/CreateApp"
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("CreateApp failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return c.handleErrorResponse(resp, "CreateApp")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("CreateApp: %s/%s version %s created successfully",
|
|
||||||
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowApp retrieves a single application by key and region
|
|
||||||
// Maps to POST /auth/ctrl/ShowApp
|
|
||||||
func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp"
|
|
||||||
|
|
||||||
filter := AppFilter{
|
|
||||||
App: App{Key: appKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return App{}, fmt.Errorf("ShowApp failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
|
|
||||||
appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return App{}, c.handleErrorResponse(resp, "ShowApp")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse streaming JSON response
|
|
||||||
var apps []App
|
|
||||||
if err := c.parseStreamingResponse(resp, &apps); err != nil {
|
|
||||||
return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(apps) == 0 {
|
|
||||||
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
|
|
||||||
appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return apps[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowApps retrieves all applications matching the filter criteria
|
|
||||||
// Maps to POST /auth/ctrl/ShowApp
|
|
||||||
func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]App, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp"
|
|
||||||
|
|
||||||
filter := AppFilter{
|
|
||||||
App: App{Key: appKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ShowApps failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
|
||||||
return nil, c.handleErrorResponse(resp, "ShowApps")
|
|
||||||
}
|
|
||||||
|
|
||||||
var apps []App
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return apps, nil // Return empty slice for not found
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.parseStreamingResponse(resp, &apps); err != nil {
|
|
||||||
return nil, fmt.Errorf("ShowApps failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("ShowApps: found %d apps matching criteria", len(apps))
|
|
||||||
return apps, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateApp updates the definition of an application
|
|
||||||
// Maps to POST /auth/ctrl/UpdateApp
|
|
||||||
func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/UpdateApp"
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("UpdateApp failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return c.handleErrorResponse(resp, "UpdateApp")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("UpdateApp: %s/%s version %s updated successfully",
|
|
||||||
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteApp removes an application from the specified region
|
|
||||||
// Maps to POST /auth/ctrl/DeleteApp
|
|
||||||
func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp"
|
|
||||||
|
|
||||||
filter := AppFilter{
|
|
||||||
App: App{Key: appKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("DeleteApp failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 404 is acceptable for delete operations (already deleted)
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
|
||||||
return c.handleErrorResponse(resp, "DeleteApp")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("DeleteApp: %s/%s version %s deleted successfully",
|
|
||||||
appKey.Organization, appKey.Name, appKey.Version)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseStreamingResponse parses the EdgeXR streaming JSON response format
|
|
||||||
func (c *Client) parseStreamingResponse(resp *http.Response, result interface{}) error {
|
|
||||||
var responses []Response[App]
|
|
||||||
|
|
||||||
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
|
|
||||||
// On permission denied, Edge API returns just an empty array []!
|
|
||||||
if len(line) == 0 || line[0] == '[' {
|
|
||||||
return fmt.Errorf("%w", ErrFaultyResponsePerhaps403)
|
|
||||||
}
|
|
||||||
var response Response[App]
|
|
||||||
if err := json.Unmarshal(line, &response); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
responses = append(responses, response)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if parseErr != nil {
|
|
||||||
return parseErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract data from responses
|
|
||||||
var apps []App
|
|
||||||
var messages []string
|
|
||||||
|
|
||||||
for _, response := range responses {
|
|
||||||
if response.HasData() {
|
|
||||||
apps = append(apps, response.Data)
|
|
||||||
}
|
|
||||||
if response.IsMessage() {
|
|
||||||
messages = append(messages, response.Data.GetMessage())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have error messages, return them
|
|
||||||
if len(messages) > 0 {
|
|
||||||
return &APIError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Messages: messages,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set result based on type
|
|
||||||
switch v := result.(type) {
|
|
||||||
case *[]App:
|
|
||||||
*v = apps
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported result type: %T", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTransport creates an HTTP transport with current client settings
|
|
||||||
func (c *Client) getTransport() *sdkhttp.Transport {
|
|
||||||
return sdkhttp.NewTransport(
|
|
||||||
sdkhttp.RetryOptions{
|
|
||||||
MaxRetries: c.RetryOpts.MaxRetries,
|
|
||||||
InitialDelay: c.RetryOpts.InitialDelay,
|
|
||||||
MaxDelay: c.RetryOpts.MaxDelay,
|
|
||||||
Multiplier: c.RetryOpts.Multiplier,
|
|
||||||
RetryableHTTPStatusCodes: c.RetryOpts.RetryableHTTPStatusCodes,
|
|
||||||
},
|
|
||||||
c.AuthProvider,
|
|
||||||
c.Logger,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleErrorResponse creates an appropriate error from HTTP error response
|
|
||||||
func (c *Client) handleErrorResponse(resp *http.Response, operation string) error {
|
|
||||||
|
|
||||||
messages := []string{
|
|
||||||
fmt.Sprintf("%s failed with status %d", operation, resp.StatusCode),
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes := []byte{}
|
|
||||||
|
|
||||||
if resp.Body != nil {
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
bodyBytes, _ = io.ReadAll(resp.Body)
|
|
||||||
messages = append(messages, string(bodyBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &APIError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Messages: messages,
|
|
||||||
Body: bodyBytes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,409 +0,0 @@
|
||||||
// ABOUTME: Unit tests for App management APIs using httptest mock server
|
|
||||||
// ABOUTME: Tests create, show, list, and delete operations with error conditions
|
|
||||||
|
|
||||||
package edgeconnect
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateApp(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input *NewAppInput
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful creation",
|
|
||||||
input: &NewAppInput{
|
|
||||||
Region: "us-west",
|
|
||||||
App: App{
|
|
||||||
Key: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Deployment: "kubernetes",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"message": "success"}`,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "validation error",
|
|
||||||
input: &NewAppInput{
|
|
||||||
Region: "us-west",
|
|
||||||
App: App{
|
|
||||||
Key: AppKey{
|
|
||||||
Organization: "",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 400,
|
|
||||||
mockResponse: `{"message": "organization is required"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/CreateApp", r.URL.Path)
|
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
err := client.CreateApp(ctx, tt.input)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShowApp(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
appKey AppKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
expectNotFound bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful show",
|
|
||||||
appKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testapp", "version": "1.0.0"}, "deployment": "kubernetes"}}
|
|
||||||
`,
|
|
||||||
expectError: false,
|
|
||||||
expectNotFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "app not found",
|
|
||||||
appKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: "",
|
|
||||||
expectError: true,
|
|
||||||
expectNotFound: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
if tt.mockResponse != "" {
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
app, err := client.ShowApp(ctx, tt.appKey, tt.region)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.expectNotFound {
|
|
||||||
assert.Contains(t, err.Error(), "resource not found")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.appKey.Organization, app.Key.Organization)
|
|
||||||
assert.Equal(t, tt.appKey.Name, app.Key.Name)
|
|
||||||
assert.Equal(t, tt.appKey.Version, app.Key.Version)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShowApps(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path)
|
|
||||||
|
|
||||||
// Verify request body
|
|
||||||
var filter AppFilter
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&filter)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "testorg", filter.App.Key.Organization)
|
|
||||||
assert.Equal(t, "us-west", filter.Region)
|
|
||||||
|
|
||||||
// Return multiple apps
|
|
||||||
response := `{"data": {"key": {"organization": "testorg", "name": "app1", "version": "1.0.0"}, "deployment": "kubernetes"}}
|
|
||||||
{"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}}
|
|
||||||
`
|
|
||||||
w.WriteHeader(200)
|
|
||||||
_, _ = w.Write([]byte(response))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
apps, err := client.ShowApps(ctx, AppKey{Organization: "testorg"}, "us-west")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, apps, 2)
|
|
||||||
assert.Equal(t, "app1", apps[0].Key.Name)
|
|
||||||
assert.Equal(t, "app2", apps[1].Key.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateApp(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input *UpdateAppInput
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful update",
|
|
||||||
input: &UpdateAppInput{
|
|
||||||
Region: "us-west",
|
|
||||||
App: App{
|
|
||||||
Key: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Deployment: "kubernetes",
|
|
||||||
ImagePath: "nginx:latest",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"message": "success"}`,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "validation error",
|
|
||||||
input: &UpdateAppInput{
|
|
||||||
Region: "us-west",
|
|
||||||
App: App{
|
|
||||||
Key: AppKey{
|
|
||||||
Organization: "",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 400,
|
|
||||||
mockResponse: `{"message": "organization is required"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "app not found",
|
|
||||||
input: &UpdateAppInput{
|
|
||||||
Region: "us-west",
|
|
||||||
App: App{
|
|
||||||
Key: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: `{"message": "app not found"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/UpdateApp", r.URL.Path)
|
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
||||||
|
|
||||||
// Verify request body
|
|
||||||
var input UpdateAppInput
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.input.Region, input.Region)
|
|
||||||
assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
err := client.UpdateApp(ctx, tt.input)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteApp(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
appKey AppKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful deletion",
|
|
||||||
appKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "already deleted (404 ok)",
|
|
||||||
appKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "server error",
|
|
||||||
appKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 500,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/DeleteApp", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := client.DeleteApp(ctx, tt.appKey, tt.region)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientOptions(t *testing.T) {
|
|
||||||
t.Run("with auth provider", func(t *testing.T) {
|
|
||||||
authProvider := NewStaticTokenProvider("test-token")
|
|
||||||
client := NewClient("https://example.com",
|
|
||||||
WithAuthProvider(authProvider),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, authProvider, client.AuthProvider)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("with custom HTTP client", func(t *testing.T) {
|
|
||||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
client := NewClient("https://example.com",
|
|
||||||
WithHTTPClient(httpClient),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, httpClient, client.HTTPClient)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("with retry options", func(t *testing.T) {
|
|
||||||
retryOpts := RetryOptions{MaxRetries: 5}
|
|
||||||
client := NewClient("https://example.com",
|
|
||||||
WithRetryOptions(retryOpts),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, 5, client.RetryOpts.MaxRetries)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIError(t *testing.T) {
|
|
||||||
err := &APIError{
|
|
||||||
StatusCode: 400,
|
|
||||||
Messages: []string{"validation failed", "name is required"},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Contains(t, err.Error(), "validation failed")
|
|
||||||
assert.Equal(t, 400, err.StatusCode)
|
|
||||||
assert.Len(t, err.Messages, 2)
|
|
||||||
}
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
// ABOUTME: Unit tests for authentication providers including username/password token flow
|
|
||||||
// ABOUTME: Tests token caching, login flow, and error conditions with mock servers
|
|
||||||
|
|
||||||
package edgeconnect
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStaticTokenProvider(t *testing.T) {
|
|
||||||
provider := NewStaticTokenProvider("test-token-123")
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "Bearer test-token-123", req.Header.Get("Authorization"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStaticTokenProvider_EmptyToken(t *testing.T) {
|
|
||||||
provider := NewStaticTokenProvider("")
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, req.Header.Get("Authorization"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsernamePasswordProvider_Success(t *testing.T) {
|
|
||||||
// Mock login server
|
|
||||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/login", r.URL.Path)
|
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
||||||
|
|
||||||
// Verify request body
|
|
||||||
var creds map[string]string
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&creds)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "testuser", creds["username"])
|
|
||||||
assert.Equal(t, "testpass", creds["password"])
|
|
||||||
|
|
||||||
// Return token
|
|
||||||
response := map[string]string{"token": "dynamic-token-456"}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(response)
|
|
||||||
}))
|
|
||||||
defer loginServer.Close()
|
|
||||||
|
|
||||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "Bearer dynamic-token-456", req.Header.Get("Authorization"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsernamePasswordProvider_LoginFailure(t *testing.T) {
|
|
||||||
// Mock login server that returns error
|
|
||||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
_, _ = w.Write([]byte("Invalid credentials"))
|
|
||||||
}))
|
|
||||||
defer loginServer.Close()
|
|
||||||
|
|
||||||
provider := NewUsernamePasswordProvider(loginServer.URL, "baduser", "badpass", nil)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "login failed with status 401")
|
|
||||||
assert.Contains(t, err.Error(), "Invalid credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsernamePasswordProvider_TokenCaching(t *testing.T) {
|
|
||||||
callCount := 0
|
|
||||||
|
|
||||||
// Mock login server that tracks calls
|
|
||||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
callCount++
|
|
||||||
response := map[string]string{"token": "cached-token-789"}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(response)
|
|
||||||
}))
|
|
||||||
defer loginServer.Close()
|
|
||||||
|
|
||||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// First request should call login
|
|
||||||
req1, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
err1 := provider.Attach(ctx, req1)
|
|
||||||
require.NoError(t, err1)
|
|
||||||
assert.Equal(t, "Bearer cached-token-789", req1.Header.Get("Authorization"))
|
|
||||||
assert.Equal(t, 1, callCount)
|
|
||||||
|
|
||||||
// Second request should use cached token (no additional login call)
|
|
||||||
req2, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
err2 := provider.Attach(ctx, req2)
|
|
||||||
require.NoError(t, err2)
|
|
||||||
assert.Equal(t, "Bearer cached-token-789", req2.Header.Get("Authorization"))
|
|
||||||
assert.Equal(t, 1, callCount) // Still only 1 call
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) {
|
|
||||||
callCount := 0
|
|
||||||
|
|
||||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
callCount++
|
|
||||||
response := map[string]string{"token": "refreshed-token-999"}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(response)
|
|
||||||
}))
|
|
||||||
defer loginServer.Close()
|
|
||||||
|
|
||||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
|
||||||
|
|
||||||
// Manually set expired token
|
|
||||||
provider.mu.Lock()
|
|
||||||
provider.cachedToken = "expired-token"
|
|
||||||
provider.tokenExpiry = time.Now().Add(-1 * time.Hour) // Already expired
|
|
||||||
provider.mu.Unlock()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "Bearer refreshed-token-999", req.Header.Get("Authorization"))
|
|
||||||
assert.Equal(t, 1, callCount) // New token retrieved
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) {
|
|
||||||
callCount := 0
|
|
||||||
|
|
||||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
callCount++
|
|
||||||
response := map[string]string{"token": "new-token-after-invalidation"}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(response)
|
|
||||||
}))
|
|
||||||
defer loginServer.Close()
|
|
||||||
|
|
||||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// First request to get token
|
|
||||||
req1, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
err1 := provider.Attach(ctx, req1)
|
|
||||||
require.NoError(t, err1)
|
|
||||||
assert.Equal(t, 1, callCount)
|
|
||||||
|
|
||||||
// Invalidate token
|
|
||||||
provider.InvalidateToken()
|
|
||||||
|
|
||||||
// Next request should get new token
|
|
||||||
req2, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
err2 := provider.Attach(ctx, req2)
|
|
||||||
require.NoError(t, err2)
|
|
||||||
assert.Equal(t, "Bearer new-token-after-invalidation", req2.Header.Get("Authorization"))
|
|
||||||
assert.Equal(t, 2, callCount) // New login call made
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsernamePasswordProvider_BadJSONResponse(t *testing.T) {
|
|
||||||
// Mock server returning invalid JSON
|
|
||||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte("invalid json response"))
|
|
||||||
}))
|
|
||||||
defer loginServer.Close()
|
|
||||||
|
|
||||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "error parsing JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoAuthProvider(t *testing.T) {
|
|
||||||
provider := NewNoAuthProvider()
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, req.Header.Get("Authorization"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewClientWithCredentials(t *testing.T) {
|
|
||||||
client := NewClientWithCredentials("https://example.com", "testuser", "testpass")
|
|
||||||
|
|
||||||
assert.Equal(t, "https://example.com", client.BaseURL)
|
|
||||||
|
|
||||||
// Check that auth provider is UsernamePasswordProvider
|
|
||||||
authProvider, ok := client.AuthProvider.(*UsernamePasswordProvider)
|
|
||||||
require.True(t, ok, "AuthProvider should be UsernamePasswordProvider")
|
|
||||||
assert.Equal(t, "testuser", authProvider.Username)
|
|
||||||
assert.Equal(t, "testpass", authProvider.Password)
|
|
||||||
assert.Equal(t, "https://example.com", authProvider.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth
|
|
||||||
// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations
|
|
||||||
|
|
||||||
package edgeconnect
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client represents the EdgeXR Master Controller SDK client
|
|
||||||
type Client struct {
|
|
||||||
BaseURL string
|
|
||||||
HTTPClient *http.Client
|
|
||||||
AuthProvider AuthProvider
|
|
||||||
RetryOpts RetryOptions
|
|
||||||
Logger Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// RetryOptions configures retry behavior for API calls
|
|
||||||
type RetryOptions struct {
|
|
||||||
MaxRetries int
|
|
||||||
InitialDelay time.Duration
|
|
||||||
MaxDelay time.Duration
|
|
||||||
Multiplier float64
|
|
||||||
RetryableHTTPStatusCodes []int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logger interface for optional logging
|
|
||||||
type Logger interface {
|
|
||||||
Printf(format string, v ...interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultRetryOptions returns sensible default retry configuration
|
|
||||||
func DefaultRetryOptions() RetryOptions {
|
|
||||||
return RetryOptions{
|
|
||||||
MaxRetries: 3,
|
|
||||||
InitialDelay: 1 * time.Second,
|
|
||||||
MaxDelay: 30 * time.Second,
|
|
||||||
Multiplier: 2.0,
|
|
||||||
RetryableHTTPStatusCodes: []int{
|
|
||||||
http.StatusRequestTimeout,
|
|
||||||
http.StatusTooManyRequests,
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
http.StatusBadGateway,
|
|
||||||
http.StatusServiceUnavailable,
|
|
||||||
http.StatusGatewayTimeout,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option represents a configuration option for the client
|
|
||||||
type Option func(*Client)
|
|
||||||
|
|
||||||
// WithHTTPClient sets a custom HTTP client
|
|
||||||
func WithHTTPClient(client *http.Client) Option {
|
|
||||||
return func(c *Client) {
|
|
||||||
c.HTTPClient = client
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithAuthProvider sets the authentication provider
|
|
||||||
func WithAuthProvider(auth AuthProvider) Option {
|
|
||||||
return func(c *Client) {
|
|
||||||
c.AuthProvider = auth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithRetryOptions sets retry configuration
|
|
||||||
func WithRetryOptions(opts RetryOptions) Option {
|
|
||||||
return func(c *Client) {
|
|
||||||
c.RetryOpts = opts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithLogger sets a logger for debugging
|
|
||||||
func WithLogger(logger Logger) Option {
|
|
||||||
return func(c *Client) {
|
|
||||||
c.Logger = logger
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient creates a new EdgeXR SDK client
|
|
||||||
func NewClient(baseURL string, options ...Option) *Client {
|
|
||||||
client := &Client{
|
|
||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
||||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
AuthProvider: NewNoAuthProvider(),
|
|
||||||
RetryOpts: DefaultRetryOptions(),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range options {
|
|
||||||
opt(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClientWithCredentials creates a new EdgeXR SDK client with username/password authentication
|
|
||||||
// This matches the existing client pattern from client/client.go
|
|
||||||
func NewClientWithCredentials(baseURL, username, password string, options ...Option) *Client {
|
|
||||||
client := &Client{
|
|
||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
||||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
AuthProvider: NewUsernamePasswordProvider(baseURL, username, password, nil),
|
|
||||||
RetryOpts: DefaultRetryOptions(),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range options {
|
|
||||||
opt(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
// logf logs a message if a logger is configured
|
|
||||||
func (c *Client) logf(format string, v ...interface{}) {
|
|
||||||
if c.Logger != nil {
|
|
||||||
c.Logger.Printf(format, v...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
// ABOUTME: Cloudlet management APIs for EdgeXR Master Controller
|
|
||||||
// ABOUTME: Provides typed methods for creating, querying, and managing edge cloudlets
|
|
||||||
|
|
||||||
package edgeconnect
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateCloudlet creates a new cloudlet in the specified region
|
|
||||||
// Maps to POST /auth/ctrl/CreateCloudlet
|
|
||||||
func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/CreateCloudlet"
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("CreateCloudlet failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return c.handleErrorResponse(resp, "CreateCloudlet")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("CreateCloudlet: %s/%s created successfully",
|
|
||||||
input.Cloudlet.Key.Organization, input.Cloudlet.Key.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowCloudlet retrieves a single cloudlet by key and region
|
|
||||||
// Maps to POST /auth/ctrl/ShowCloudlet
|
|
||||||
func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) (Cloudlet, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet"
|
|
||||||
|
|
||||||
filter := CloudletFilter{
|
|
||||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return Cloudlet{}, c.handleErrorResponse(resp, "ShowCloudlet")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse streaming JSON response
|
|
||||||
var cloudlets []Cloudlet
|
|
||||||
if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil {
|
|
||||||
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cloudlets) == 0 {
|
|
||||||
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cloudlets[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowCloudlets retrieves all cloudlets matching the filter criteria
|
|
||||||
// Maps to POST /auth/ctrl/ShowCloudlet
|
|
||||||
func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, region string) ([]Cloudlet, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet"
|
|
||||||
|
|
||||||
filter := CloudletFilter{
|
|
||||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ShowCloudlets failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
|
||||||
return nil, c.handleErrorResponse(resp, "ShowCloudlets")
|
|
||||||
}
|
|
||||||
|
|
||||||
var cloudlets []Cloudlet
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return cloudlets, nil // Return empty slice for not found
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil {
|
|
||||||
return nil, fmt.Errorf("ShowCloudlets failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets))
|
|
||||||
return cloudlets, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteCloudlet removes a cloudlet from the specified region
|
|
||||||
// Maps to POST /auth/ctrl/DeleteCloudlet
|
|
||||||
func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteCloudlet"
|
|
||||||
|
|
||||||
filter := CloudletFilter{
|
|
||||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("DeleteCloudlet failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 404 is acceptable for delete operations (already deleted)
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
|
||||||
return c.handleErrorResponse(resp, "DeleteCloudlet")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("DeleteCloudlet: %s/%s deleted successfully",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCloudletManifest retrieves the deployment manifest for a cloudlet
|
|
||||||
// Maps to POST /auth/ctrl/GetCloudletManifest
|
|
||||||
func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletManifest, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletManifest"
|
|
||||||
|
|
||||||
filter := CloudletFilter{
|
|
||||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("GetCloudletManifest failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return nil, c.handleErrorResponse(resp, "GetCloudletManifest")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the response as CloudletManifest
|
|
||||||
var manifest CloudletManifest
|
|
||||||
if err := c.parseDirectJSONResponse(resp, &manifest); err != nil {
|
|
||||||
return nil, fmt.Errorf("GetCloudletManifest failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("GetCloudletManifest: retrieved manifest for %s/%s",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name)
|
|
||||||
|
|
||||||
return &manifest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCloudletResourceUsage retrieves resource usage information for a cloudlet
|
|
||||||
// Maps to POST /auth/ctrl/GetCloudletResourceUsage
|
|
||||||
func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletResourceUsage, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletResourceUsage"
|
|
||||||
|
|
||||||
filter := CloudletFilter{
|
|
||||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return nil, c.handleErrorResponse(resp, "GetCloudletResourceUsage")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the response as CloudletResourceUsage
|
|
||||||
var usage CloudletResourceUsage
|
|
||||||
if err := c.parseDirectJSONResponse(resp, &usage); err != nil {
|
|
||||||
return nil, fmt.Errorf("GetCloudletResourceUsage failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("GetCloudletResourceUsage: retrieved usage for %s/%s",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name)
|
|
||||||
|
|
||||||
return &usage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseStreamingCloudletResponse parses the EdgeXR streaming JSON response format for cloudlets
|
|
||||||
func (c *Client) parseStreamingCloudletResponse(resp *http.Response, result interface{}) error {
|
|
||||||
var responses []Response[Cloudlet]
|
|
||||||
|
|
||||||
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
|
|
||||||
var response Response[Cloudlet]
|
|
||||||
if err := json.Unmarshal(line, &response); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
responses = append(responses, response)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if parseErr != nil {
|
|
||||||
return parseErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract data from responses
|
|
||||||
var cloudlets []Cloudlet
|
|
||||||
var messages []string
|
|
||||||
|
|
||||||
for _, response := range responses {
|
|
||||||
if response.HasData() {
|
|
||||||
cloudlets = append(cloudlets, response.Data)
|
|
||||||
}
|
|
||||||
if response.IsMessage() {
|
|
||||||
messages = append(messages, response.Data.GetMessage())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have error messages, return them
|
|
||||||
if len(messages) > 0 {
|
|
||||||
return &APIError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Messages: messages,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set result based on type
|
|
||||||
switch v := result.(type) {
|
|
||||||
case *[]Cloudlet:
|
|
||||||
*v = cloudlets
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported result type: %T", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDirectJSONResponse parses a direct JSON response (not streaming)
|
|
||||||
func (c *Client) parseDirectJSONResponse(resp *http.Response, result interface{}) error {
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
if err := decoder.Decode(result); err != nil {
|
|
||||||
return fmt.Errorf("failed to decode JSON response: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,408 +0,0 @@
|
||||||
// ABOUTME: Unit tests for Cloudlet management APIs using httptest mock server
|
|
||||||
// ABOUTME: Tests create, show, list, delete, manifest, and resource usage operations
|
|
||||||
|
|
||||||
package edgeconnect
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateCloudlet(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input *NewCloudletInput
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful creation",
|
|
||||||
input: &NewCloudletInput{
|
|
||||||
Region: "us-west",
|
|
||||||
Cloudlet: Cloudlet{
|
|
||||||
Key: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
Location: Location{
|
|
||||||
Latitude: 37.7749,
|
|
||||||
Longitude: -122.4194,
|
|
||||||
},
|
|
||||||
IpSupport: "IpSupportDynamic",
|
|
||||||
NumDynamicIps: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"message": "success"}`,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "validation error",
|
|
||||||
input: &NewCloudletInput{
|
|
||||||
Region: "us-west",
|
|
||||||
Cloudlet: Cloudlet{
|
|
||||||
Key: CloudletKey{
|
|
||||||
Organization: "",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 400,
|
|
||||||
mockResponse: `{"message": "organization is required"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/CreateCloudlet", r.URL.Path)
|
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
err := client.CreateCloudlet(ctx, tt.input)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShowCloudlet(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cloudletKey CloudletKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
expectNotFound bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful show",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"data": {"key": {"organization": "cloudletorg", "name": "testcloudlet"}, "state": "Ready", "location": {"latitude": 37.7749, "longitude": -122.4194}}}
|
|
||||||
`,
|
|
||||||
expectError: false,
|
|
||||||
expectNotFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cloudlet not found",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: "",
|
|
||||||
expectError: true,
|
|
||||||
expectNotFound: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
if tt.mockResponse != "" {
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
cloudlet, err := client.ShowCloudlet(ctx, tt.cloudletKey, tt.region)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.expectNotFound {
|
|
||||||
assert.Contains(t, err.Error(), "resource not found")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.cloudletKey.Organization, cloudlet.Key.Organization)
|
|
||||||
assert.Equal(t, tt.cloudletKey.Name, cloudlet.Key.Name)
|
|
||||||
assert.Equal(t, "Ready", cloudlet.State)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShowCloudlets(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path)
|
|
||||||
|
|
||||||
// Verify request body
|
|
||||||
var filter CloudletFilter
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&filter)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "cloudletorg", filter.Cloudlet.Key.Organization)
|
|
||||||
assert.Equal(t, "us-west", filter.Region)
|
|
||||||
|
|
||||||
// Return multiple cloudlets
|
|
||||||
response := `{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet1"}, "state": "Ready"}}
|
|
||||||
{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}}
|
|
||||||
`
|
|
||||||
w.WriteHeader(200)
|
|
||||||
_, _ = w.Write([]byte(response))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
cloudlets, err := client.ShowCloudlets(ctx, CloudletKey{Organization: "cloudletorg"}, "us-west")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, cloudlets, 2)
|
|
||||||
assert.Equal(t, "cloudlet1", cloudlets[0].Key.Name)
|
|
||||||
assert.Equal(t, "Ready", cloudlets[0].State)
|
|
||||||
assert.Equal(t, "cloudlet2", cloudlets[1].Key.Name)
|
|
||||||
assert.Equal(t, "Creating", cloudlets[1].State)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteCloudlet(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cloudletKey CloudletKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful deletion",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "already deleted (404 ok)",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "server error",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 500,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/DeleteCloudlet", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := client.DeleteCloudlet(ctx, tt.cloudletKey, tt.region)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetCloudletManifest(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cloudletKey CloudletKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
expectNotFound bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful manifest retrieval",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"manifest": "apiVersion: v1\nkind: Deployment\nmetadata:\n name: test", "last_modified": "2024-01-01T00:00:00Z"}`,
|
|
||||||
expectError: false,
|
|
||||||
expectNotFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "manifest not found",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: "",
|
|
||||||
expectError: true,
|
|
||||||
expectNotFound: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletManifest", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
if tt.mockResponse != "" {
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
manifest, err := client.GetCloudletManifest(ctx, tt.cloudletKey, tt.region)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.expectNotFound {
|
|
||||||
assert.Contains(t, err.Error(), "resource not found")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotNil(t, manifest)
|
|
||||||
assert.Contains(t, manifest.Manifest, "apiVersion: v1")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetCloudletResourceUsage(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cloudletKey CloudletKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
expectNotFound bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful usage retrieval",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}, "region": "us-west", "usage": {"cpu": "50%", "memory": "30%", "disk": "20%"}}`,
|
|
||||||
expectError: false,
|
|
||||||
expectNotFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "usage not found",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: "",
|
|
||||||
expectError: true,
|
|
||||||
expectNotFound: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletResourceUsage", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
if tt.mockResponse != "" {
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
usage, err := client.GetCloudletResourceUsage(ctx, tt.cloudletKey, tt.region)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.expectNotFound {
|
|
||||||
assert.Contains(t, err.Error(), "resource not found")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotNil(t, usage)
|
|
||||||
assert.Equal(t, "cloudletorg", usage.CloudletKey.Organization)
|
|
||||||
assert.Equal(t, "testcloudlet", usage.CloudletKey.Name)
|
|
||||||
assert.Equal(t, "us-west", usage.Region)
|
|
||||||
assert.Contains(t, usage.Usage, "cpu")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,293 +0,0 @@
|
||||||
// ABOUTME: Application instance lifecycle management APIs for EdgeXR Master Controller
|
|
||||||
// ABOUTME: Provides typed methods for creating, querying, and deleting application instances
|
|
||||||
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateAppInstance creates a new application instance in the specified region
|
|
||||||
// Maps to POST /auth/ctrl/CreateAppInst
|
|
||||||
func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInput) error {
|
|
||||||
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/CreateAppInst"
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("CreateAppInstance failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return c.handleErrorResponse(resp, "CreateAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse streaming JSON response
|
|
||||||
if _, err = parseStreamingResponse[AppInstance](resp); err != nil {
|
|
||||||
return fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("CreateAppInstance: %s/%s created successfully",
|
|
||||||
input.AppInst.Key.Organization, input.AppInst.Key.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowAppInstance retrieves a single application instance by key and region
|
|
||||||
// Maps to POST /auth/ctrl/ShowAppInst
|
|
||||||
func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) (AppInstance, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
|
|
||||||
|
|
||||||
filter := AppInstanceFilter{
|
|
||||||
AppInstance: AppInstance{Key: appInstKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return AppInstance{}, fmt.Errorf("app instance %s/%s: %w",
|
|
||||||
appInstKey.Organization, appInstKey.Name, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return AppInstance{}, c.handleErrorResponse(resp, "ShowAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse streaming JSON response
|
|
||||||
var appInstances []AppInstance
|
|
||||||
if appInstances, err = parseStreamingResponse[AppInstance](resp); err != nil {
|
|
||||||
return AppInstance{}, fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(appInstances) == 0 {
|
|
||||||
return AppInstance{}, fmt.Errorf("app instance %s/%s in region %s: %w",
|
|
||||||
appInstKey.Organization, appInstKey.Name, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return appInstances[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowAppInstances retrieves all application instances matching the filter criteria
|
|
||||||
// Maps to POST /auth/ctrl/ShowAppInst
|
|
||||||
func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) ([]AppInstance, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
|
|
||||||
|
|
||||||
filter := AppInstanceFilter{
|
|
||||||
AppInstance: AppInstance{Key: appInstKey, AppKey: appKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
|
||||||
return nil, c.handleErrorResponse(resp, "ShowAppInstances")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return []AppInstance{}, nil // Return empty slice for not found
|
|
||||||
}
|
|
||||||
|
|
||||||
var appInstances []AppInstance
|
|
||||||
if appInstances, err = parseStreamingResponse[AppInstance](resp); err != nil {
|
|
||||||
return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances))
|
|
||||||
return appInstances, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateAppInstance updates an application instance and then refreshes it
|
|
||||||
// Maps to POST /auth/ctrl/UpdateAppInst
|
|
||||||
func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstanceInput) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/UpdateAppInst"
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("UpdateAppInstance failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return c.handleErrorResponse(resp, "UpdateAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("UpdateAppInstance: %s/%s updated successfully",
|
|
||||||
input.AppInst.Key.Organization, input.AppInst.Key.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshAppInstance refreshes an application instance's state
|
|
||||||
// Maps to POST /auth/ctrl/RefreshAppInst
|
|
||||||
func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/RefreshAppInst"
|
|
||||||
|
|
||||||
filter := AppInstanceFilter{
|
|
||||||
AppInstance: AppInstance{Key: appInstKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("RefreshAppInstance failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return c.handleErrorResponse(resp, "RefreshAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("RefreshAppInstance: %s/%s refreshed successfully",
|
|
||||||
appInstKey.Organization, appInstKey.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteAppInstance removes an application instance
|
|
||||||
// Maps to POST /auth/ctrl/DeleteAppInst
|
|
||||||
func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst"
|
|
||||||
|
|
||||||
input := DeleteAppInstanceInput{
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
input.AppInst.Key = appInstKey
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("DeleteAppInstance failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 404 is acceptable for delete operations (already deleted)
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
|
||||||
return c.handleErrorResponse(resp, "DeleteAppInstance")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("DeleteAppInstance: %s/%s deleted successfully",
|
|
||||||
appInstKey.Organization, appInstKey.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances
|
|
||||||
func parseStreamingResponse[T Message](resp *http.Response) ([]T, error) {
|
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return []T{}, fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo finish check the responses, test them, and make a unify result, probably need
|
|
||||||
// to update the response parameter to the message type e.g. App or AppInst
|
|
||||||
isV2, err := isV2Response(bodyBytes)
|
|
||||||
if err != nil {
|
|
||||||
return []T{}, fmt.Errorf("failed to parse streaming response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isV2 {
|
|
||||||
resultV2, err := parseStreamingResponseV2[T](resp.StatusCode, bodyBytes)
|
|
||||||
if err != nil {
|
|
||||||
return []T{}, err
|
|
||||||
}
|
|
||||||
return resultV2, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
resultV1, err := parseStreamingResponseV1[T](resp.StatusCode, bodyBytes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !resultV1.IsSuccessful() {
|
|
||||||
return []T{}, resultV1.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultV1.GetData(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseStreamingResponseV1[T Message](statusCode int, bodyBytes []byte) (Responses[T], error) {
|
|
||||||
// Fall back to streaming format (v1 API format)
|
|
||||||
var responses Responses[T]
|
|
||||||
responses.StatusCode = statusCode
|
|
||||||
|
|
||||||
decoder := json.NewDecoder(bytes.NewReader(bodyBytes))
|
|
||||||
for {
|
|
||||||
var d Response[T]
|
|
||||||
if err := decoder.Decode(&d); err != nil {
|
|
||||||
if err.Error() == "EOF" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return Responses[T]{}, fmt.Errorf("error in parsing json object into Message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if d.Result.Message != "" && d.Result.Code != 0 {
|
|
||||||
responses.StatusCode = d.Result.Code
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(d.Data.GetMessage(), "CreateError") {
|
|
||||||
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "CreateError"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(d.Data.GetMessage(), "UpdateError") {
|
|
||||||
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "UpdateError"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(d.Data.GetMessage(), "DeleteError") {
|
|
||||||
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "DeleteError"))
|
|
||||||
}
|
|
||||||
|
|
||||||
responses.Responses = append(responses.Responses, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
return responses, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isV2Response(bodyBytes []byte) (bool, error) {
|
|
||||||
if len(bodyBytes) == 0 {
|
|
||||||
return false, fmt.Errorf("malformatted response body")
|
|
||||||
}
|
|
||||||
|
|
||||||
return bodyBytes[0] == '[', nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseStreamingResponseV2[T Message](statusCode int, bodyBytes []byte) ([]T, error) {
|
|
||||||
var result []T
|
|
||||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
|
||||||
return result, fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,527 +0,0 @@
|
||||||
// ABOUTME: Unit tests for AppInstance management APIs using httptest mock server
|
|
||||||
// ABOUTME: Tests create, show, list, refresh, and delete operations with error conditions
|
|
||||||
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateAppInstance(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input *NewAppInstanceInput
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
errorContains string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful creation",
|
|
||||||
input: &NewAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Flavor: Flavor{Name: "m4.small"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"message": "success"}`,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "validation error",
|
|
||||||
input: &NewAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "",
|
|
||||||
Name: "testinst",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 400,
|
|
||||||
mockResponse: `{"message": "organization is required"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "HTTP 200 with CreateError message",
|
|
||||||
input: &NewAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Flavor: Flavor{Name: "m4.small"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"data":{"message":"Creating"}}
|
|
||||||
{"data":{"message":"a service has been configured"}}
|
|
||||||
{"data":{"message":"CreateError"}}
|
|
||||||
{"data":{"message":"Deleting AppInst due to failure"}}
|
|
||||||
{"data":{"message":"Deleted AppInst successfully"}}`,
|
|
||||||
expectError: true,
|
|
||||||
errorContains: "CreateError",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "HTTP 200 with result error code",
|
|
||||||
input: &NewAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Flavor: Flavor{Name: "m4.small"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"data":{"message":"Creating"}}
|
|
||||||
{"data":{"message":"a service has been configured"}}
|
|
||||||
{"data":{"message":"CreateError"}}
|
|
||||||
{"data":{"message":"Deleting AppInst due to failure"}}
|
|
||||||
{"data":{"message":"Deleted AppInst successfully"}}
|
|
||||||
{"result":{"message":"Encountered failures: Create App Inst failed: deployments.apps is forbidden: User \"system:serviceaccount:edgexr:crm-telekomop-munich\" cannot create resource \"deployments\" in API group \"apps\" in the namespace \"gitea\"","code":400}}`,
|
|
||||||
expectError: true,
|
|
||||||
errorContains: "deployments.apps is forbidden",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/CreateAppInst", r.URL.Path)
|
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
err := client.CreateAppInstance(ctx, tt.input)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.errorContains != "" {
|
|
||||||
assert.Contains(t, err.Error(), tt.errorContains)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShowAppInstance(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
appInstKey AppInstanceKey
|
|
||||||
appKey AppKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
expectNotFound bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful show",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
appKey: AppKey{Name: "testapp"},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}}
|
|
||||||
`,
|
|
||||||
expectError: false,
|
|
||||||
expectNotFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "instance not found",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
appKey: AppKey{Name: "testapp"},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: "",
|
|
||||||
expectError: true,
|
|
||||||
expectNotFound: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
if tt.mockResponse != "" {
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.expectNotFound {
|
|
||||||
assert.Contains(t, err.Error(), "resource not found")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.appInstKey.Organization, appInst.Key.Organization)
|
|
||||||
assert.Equal(t, tt.appInstKey.Name, appInst.Key.Name)
|
|
||||||
assert.Equal(t, "Ready", appInst.State)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShowAppInstances(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path)
|
|
||||||
|
|
||||||
// Verify request body
|
|
||||||
var filter AppInstanceFilter
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&filter)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "testorg", filter.AppInstance.Key.Organization)
|
|
||||||
assert.Equal(t, "us-west", filter.Region)
|
|
||||||
|
|
||||||
// Return multiple app instances
|
|
||||||
response := `{"data": {"key": {"organization": "testorg", "name": "inst1"}, "state": "Ready"}}
|
|
||||||
{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}}
|
|
||||||
`
|
|
||||||
w.WriteHeader(200)
|
|
||||||
_, _ = w.Write([]byte(response))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, appInstances, 2)
|
|
||||||
assert.Equal(t, "inst1", appInstances[0].Key.Name)
|
|
||||||
assert.Equal(t, "Ready", appInstances[0].State)
|
|
||||||
assert.Equal(t, "inst2", appInstances[1].Key.Name)
|
|
||||||
assert.Equal(t, "Creating", appInstances[1].State)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateAppInstance(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input *UpdateAppInstanceInput
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful update",
|
|
||||||
input: &UpdateAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Flavor: Flavor{Name: "m4.medium"},
|
|
||||||
PowerState: "PowerOn",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"message": "success"}`,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "validation error",
|
|
||||||
input: &UpdateAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 400,
|
|
||||||
mockResponse: `{"message": "organization is required"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "instance not found",
|
|
||||||
input: &UpdateAppInstanceInput{
|
|
||||||
Region: "us-west",
|
|
||||||
AppInst: AppInstance{
|
|
||||||
Key: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: `{"message": "app instance not found"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/UpdateAppInst", r.URL.Path)
|
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
||||||
|
|
||||||
// Verify request body
|
|
||||||
var input UpdateAppInstanceInput
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.input.Region, input.Region)
|
|
||||||
assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
err := client.UpdateAppInstance(ctx, tt.input)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshAppInstance(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
appInstKey AppInstanceKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful refresh",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "server error",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 500,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/RefreshAppInst", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := client.RefreshAppInstance(ctx, tt.appInstKey, tt.region)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteAppInstance(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
appInstKey AppInstanceKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful deletion",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "already deleted (404 ok)",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "server error",
|
|
||||||
appInstKey: AppInstanceKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testinst",
|
|
||||||
CloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 500,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/DeleteAppInst", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := client.DeleteAppInstance(ctx, tt.appInstKey, tt.region)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
// ABOUTME: Application lifecycle management APIs for EdgeXR Master Controller
|
|
||||||
// ABOUTME: Provides typed methods for creating, querying, and deleting applications
|
|
||||||
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// ErrResourceNotFound indicates the requested resource was not found
|
|
||||||
ErrResourceNotFound = fmt.Errorf("resource not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateApp creates a new application in the specified region
|
|
||||||
// Maps to POST /auth/ctrl/CreateApp
|
|
||||||
func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/CreateApp"
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("CreateApp failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return c.handleErrorResponse(resp, "CreateApp")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("CreateApp: %s/%s version %s created successfully",
|
|
||||||
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowApp retrieves a single application by key and region
|
|
||||||
// Maps to POST /auth/ctrl/ShowApp
|
|
||||||
func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp"
|
|
||||||
|
|
||||||
filter := AppFilter{
|
|
||||||
App: App{Key: appKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return App{}, fmt.Errorf("ShowApp failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
|
|
||||||
appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return App{}, c.handleErrorResponse(resp, "ShowApp")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse streaming JSON response
|
|
||||||
var apps []App
|
|
||||||
if apps, err = parseStreamingResponse[App](resp); err != nil {
|
|
||||||
return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(apps) == 0 {
|
|
||||||
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
|
|
||||||
appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return apps[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowApps retrieves all applications matching the filter criteria
|
|
||||||
// Maps to POST /auth/ctrl/ShowApp
|
|
||||||
func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]App, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp"
|
|
||||||
|
|
||||||
filter := AppFilter{
|
|
||||||
App: App{Key: appKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ShowApps failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
|
||||||
return nil, c.handleErrorResponse(resp, "ShowApps")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return []App{}, nil // Return empty slice for not found
|
|
||||||
}
|
|
||||||
|
|
||||||
var apps []App
|
|
||||||
if apps, err = parseStreamingResponse[App](resp); err != nil {
|
|
||||||
return nil, fmt.Errorf("ShowApps failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("ShowApps: found %d apps matching criteria", len(apps))
|
|
||||||
return apps, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateApp updates the definition of an application
|
|
||||||
// Maps to POST /auth/ctrl/UpdateApp
|
|
||||||
func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/UpdateApp"
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("UpdateApp failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return c.handleErrorResponse(resp, "UpdateApp")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("UpdateApp: %s/%s version %s updated successfully",
|
|
||||||
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteApp removes an application from the specified region
|
|
||||||
// Maps to POST /auth/ctrl/DeleteApp
|
|
||||||
func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp"
|
|
||||||
|
|
||||||
input := DeleteAppInput{
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
input.App.Key = appKey
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("DeleteApp failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 404 is acceptable for delete operations (already deleted)
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
|
||||||
return c.handleErrorResponse(resp, "DeleteApp")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("DeleteApp: %s/%s version %s deleted successfully",
|
|
||||||
appKey.Organization, appKey.Name, appKey.Version)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTransport creates an HTTP transport with current client settings
|
|
||||||
func (c *Client) getTransport() *sdkhttp.Transport {
|
|
||||||
return sdkhttp.NewTransport(
|
|
||||||
sdkhttp.RetryOptions{
|
|
||||||
MaxRetries: c.RetryOpts.MaxRetries,
|
|
||||||
InitialDelay: c.RetryOpts.InitialDelay,
|
|
||||||
MaxDelay: c.RetryOpts.MaxDelay,
|
|
||||||
Multiplier: c.RetryOpts.Multiplier,
|
|
||||||
RetryableHTTPStatusCodes: c.RetryOpts.RetryableHTTPStatusCodes,
|
|
||||||
},
|
|
||||||
c.AuthProvider,
|
|
||||||
c.Logger,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleErrorResponse creates an appropriate error from HTTP error response
|
|
||||||
func (c *Client) handleErrorResponse(resp *http.Response, operation string) error {
|
|
||||||
|
|
||||||
messages := []string{
|
|
||||||
fmt.Sprintf("%s failed with status %d", operation, resp.StatusCode),
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes := []byte{}
|
|
||||||
|
|
||||||
if resp.Body != nil {
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
bodyBytes, _ = io.ReadAll(resp.Body)
|
|
||||||
messages = append(messages, string(bodyBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &APIError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Messages: messages,
|
|
||||||
Body: bodyBytes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,409 +0,0 @@
|
||||||
// ABOUTME: Unit tests for App management APIs using httptest mock server
|
|
||||||
// ABOUTME: Tests create, show, list, and delete operations with error conditions
|
|
||||||
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateApp(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input *NewAppInput
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful creation",
|
|
||||||
input: &NewAppInput{
|
|
||||||
Region: "us-west",
|
|
||||||
App: App{
|
|
||||||
Key: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Deployment: "kubernetes",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"message": "success"}`,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "validation error",
|
|
||||||
input: &NewAppInput{
|
|
||||||
Region: "us-west",
|
|
||||||
App: App{
|
|
||||||
Key: AppKey{
|
|
||||||
Organization: "",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 400,
|
|
||||||
mockResponse: `{"message": "organization is required"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/CreateApp", r.URL.Path)
|
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
err := client.CreateApp(ctx, tt.input)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShowApp(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
appKey AppKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
expectNotFound bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful show",
|
|
||||||
appKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testapp", "version": "1.0.0"}, "deployment": "kubernetes"}}
|
|
||||||
`,
|
|
||||||
expectError: false,
|
|
||||||
expectNotFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "app not found",
|
|
||||||
appKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: "",
|
|
||||||
expectError: true,
|
|
||||||
expectNotFound: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
if tt.mockResponse != "" {
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
app, err := client.ShowApp(ctx, tt.appKey, tt.region)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.expectNotFound {
|
|
||||||
assert.Contains(t, err.Error(), "resource not found")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.appKey.Organization, app.Key.Organization)
|
|
||||||
assert.Equal(t, tt.appKey.Name, app.Key.Name)
|
|
||||||
assert.Equal(t, tt.appKey.Version, app.Key.Version)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShowApps(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path)
|
|
||||||
|
|
||||||
// Verify request body
|
|
||||||
var filter AppFilter
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&filter)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "testorg", filter.App.Key.Organization)
|
|
||||||
assert.Equal(t, "us-west", filter.Region)
|
|
||||||
|
|
||||||
// Return multiple apps
|
|
||||||
response := `{"data": {"key": {"organization": "testorg", "name": "app1", "version": "1.0.0"}, "deployment": "kubernetes"}}
|
|
||||||
{"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}}
|
|
||||||
`
|
|
||||||
w.WriteHeader(200)
|
|
||||||
_, _ = w.Write([]byte(response))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
apps, err := client.ShowApps(ctx, AppKey{Organization: "testorg"}, "us-west")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, apps, 2)
|
|
||||||
assert.Equal(t, "app1", apps[0].Key.Name)
|
|
||||||
assert.Equal(t, "app2", apps[1].Key.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateApp(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input *UpdateAppInput
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful update",
|
|
||||||
input: &UpdateAppInput{
|
|
||||||
Region: "us-west",
|
|
||||||
App: App{
|
|
||||||
Key: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
Deployment: "kubernetes",
|
|
||||||
ImagePath: "nginx:latest",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"message": "success"}`,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "validation error",
|
|
||||||
input: &UpdateAppInput{
|
|
||||||
Region: "us-west",
|
|
||||||
App: App{
|
|
||||||
Key: AppKey{
|
|
||||||
Organization: "",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 400,
|
|
||||||
mockResponse: `{"message": "organization is required"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "app not found",
|
|
||||||
input: &UpdateAppInput{
|
|
||||||
Region: "us-west",
|
|
||||||
App: App{
|
|
||||||
Key: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: `{"message": "app not found"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/UpdateApp", r.URL.Path)
|
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
||||||
|
|
||||||
// Verify request body
|
|
||||||
var input UpdateAppInput
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.input.Region, input.Region)
|
|
||||||
assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
err := client.UpdateApp(ctx, tt.input)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteApp(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
appKey AppKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful deletion",
|
|
||||||
appKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "already deleted (404 ok)",
|
|
||||||
appKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "server error",
|
|
||||||
appKey: AppKey{
|
|
||||||
Organization: "testorg",
|
|
||||||
Name: "testapp",
|
|
||||||
Version: "1.0.0",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 500,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/DeleteApp", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := client.DeleteApp(ctx, tt.appKey, tt.region)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientOptions(t *testing.T) {
|
|
||||||
t.Run("with auth provider", func(t *testing.T) {
|
|
||||||
authProvider := NewStaticTokenProvider("test-token")
|
|
||||||
client := NewClient("https://example.com",
|
|
||||||
WithAuthProvider(authProvider),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, authProvider, client.AuthProvider)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("with custom HTTP client", func(t *testing.T) {
|
|
||||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
client := NewClient("https://example.com",
|
|
||||||
WithHTTPClient(httpClient),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, httpClient, client.HTTPClient)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("with retry options", func(t *testing.T) {
|
|
||||||
retryOpts := RetryOptions{MaxRetries: 5}
|
|
||||||
client := NewClient("https://example.com",
|
|
||||||
WithRetryOptions(retryOpts),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, 5, client.RetryOpts.MaxRetries)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIError(t *testing.T) {
|
|
||||||
err := &APIError{
|
|
||||||
StatusCode: 400,
|
|
||||||
Messages: []string{"validation failed", "name is required"},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Contains(t, err.Error(), "validation failed")
|
|
||||||
assert.Equal(t, 400, err.StatusCode)
|
|
||||||
assert.Len(t, err.Messages, 2)
|
|
||||||
}
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
// ABOUTME: Authentication providers for EdgeXR Master Controller API
|
|
||||||
// ABOUTME: Supports Bearer token authentication with pluggable provider interface
|
|
||||||
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuthProvider interface for attaching authentication to requests
|
|
||||||
type AuthProvider interface {
|
|
||||||
// Attach adds authentication headers to the request
|
|
||||||
Attach(ctx context.Context, req *http.Request) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// StaticTokenProvider implements Bearer token authentication with a fixed token
|
|
||||||
type StaticTokenProvider struct {
|
|
||||||
Token string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStaticTokenProvider creates a new static token provider
|
|
||||||
func NewStaticTokenProvider(token string) *StaticTokenProvider {
|
|
||||||
return &StaticTokenProvider{Token: token}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach adds the Bearer token to the request Authorization header
|
|
||||||
func (s *StaticTokenProvider) Attach(ctx context.Context, req *http.Request) error {
|
|
||||||
if s.Token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+s.Token)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UsernamePasswordProvider implements dynamic token retrieval using username/password
|
|
||||||
// This matches the existing client/client.go RetrieveToken implementation
|
|
||||||
type UsernamePasswordProvider struct {
|
|
||||||
BaseURL string
|
|
||||||
Username string
|
|
||||||
Password string
|
|
||||||
HTTPClient *http.Client
|
|
||||||
|
|
||||||
// Token caching
|
|
||||||
mu sync.RWMutex
|
|
||||||
cachedToken string
|
|
||||||
tokenExpiry time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUsernamePasswordProvider creates a new username/password auth provider
|
|
||||||
func NewUsernamePasswordProvider(baseURL, username, password string, httpClient *http.Client) *UsernamePasswordProvider {
|
|
||||||
if httpClient == nil {
|
|
||||||
httpClient = &http.Client{Timeout: 30 * time.Second}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &UsernamePasswordProvider{
|
|
||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
||||||
Username: username,
|
|
||||||
Password: password,
|
|
||||||
HTTPClient: httpClient,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach retrieves a token (with caching) and adds it to the Authorization header
|
|
||||||
func (u *UsernamePasswordProvider) Attach(ctx context.Context, req *http.Request) error {
|
|
||||||
token, err := u.getToken(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getToken retrieves a token, using cache if valid
|
|
||||||
func (u *UsernamePasswordProvider) getToken(ctx context.Context) (string, error) {
|
|
||||||
// Check cache first
|
|
||||||
u.mu.RLock()
|
|
||||||
if u.cachedToken != "" && time.Now().Before(u.tokenExpiry) {
|
|
||||||
token := u.cachedToken
|
|
||||||
u.mu.RUnlock()
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
u.mu.RUnlock()
|
|
||||||
|
|
||||||
// Need to retrieve new token
|
|
||||||
u.mu.Lock()
|
|
||||||
defer u.mu.Unlock()
|
|
||||||
|
|
||||||
// Double-check after acquiring write lock
|
|
||||||
if u.cachedToken != "" && time.Now().Before(u.tokenExpiry) {
|
|
||||||
return u.cachedToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve token using existing RetrieveToken logic
|
|
||||||
token, err := u.retrieveToken(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache token with reasonable expiry (assume 1 hour, can be configurable)
|
|
||||||
u.cachedToken = token
|
|
||||||
u.tokenExpiry = time.Now().Add(1 * time.Hour)
|
|
||||||
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// retrieveToken implements the same logic as the existing client/client.go RetrieveToken method
|
|
||||||
func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, error) {
|
|
||||||
// Marshal credentials - same as existing implementation
|
|
||||||
jsonData, err := json.Marshal(map[string]string{
|
|
||||||
"username": u.Username,
|
|
||||||
"password": u.Password,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create request - same as existing implementation
|
|
||||||
loginURL := u.BaseURL + "/api/v1/login"
|
|
||||||
request, err := http.NewRequestWithContext(ctx, "POST", loginURL, bytes.NewBuffer(jsonData))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
// Execute request
|
|
||||||
resp, err := u.HTTPClient.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Read response body - same as existing implementation
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("error reading response body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON response - same as existing implementation
|
|
||||||
var respData struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
}
|
|
||||||
err = json.Unmarshal(body, &respData)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("error parsing JSON (status %d): %v", resp.StatusCode, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return respData.Token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateToken clears the cached token, forcing a new login on next request
|
|
||||||
func (u *UsernamePasswordProvider) InvalidateToken() {
|
|
||||||
u.mu.Lock()
|
|
||||||
defer u.mu.Unlock()
|
|
||||||
u.cachedToken = ""
|
|
||||||
u.tokenExpiry = time.Time{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoAuthProvider implements no authentication (for testing or public endpoints)
|
|
||||||
type NoAuthProvider struct{}
|
|
||||||
|
|
||||||
// NewNoAuthProvider creates a new no-auth provider
|
|
||||||
func NewNoAuthProvider() *NoAuthProvider {
|
|
||||||
return &NoAuthProvider{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach does nothing (no authentication)
|
|
||||||
func (n *NoAuthProvider) Attach(ctx context.Context, req *http.Request) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
// ABOUTME: Unit tests for authentication providers including username/password token flow
|
|
||||||
// ABOUTME: Tests token caching, login flow, and error conditions with mock servers
|
|
||||||
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStaticTokenProvider(t *testing.T) {
|
|
||||||
provider := NewStaticTokenProvider("test-token-123")
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "Bearer test-token-123", req.Header.Get("Authorization"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStaticTokenProvider_EmptyToken(t *testing.T) {
|
|
||||||
provider := NewStaticTokenProvider("")
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, req.Header.Get("Authorization"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsernamePasswordProvider_Success(t *testing.T) {
|
|
||||||
// Mock login server
|
|
||||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/login", r.URL.Path)
|
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
||||||
|
|
||||||
// Verify request body
|
|
||||||
var creds map[string]string
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&creds)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "testuser", creds["username"])
|
|
||||||
assert.Equal(t, "testpass", creds["password"])
|
|
||||||
|
|
||||||
// Return token
|
|
||||||
response := map[string]string{"token": "dynamic-token-456"}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(response)
|
|
||||||
}))
|
|
||||||
defer loginServer.Close()
|
|
||||||
|
|
||||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "Bearer dynamic-token-456", req.Header.Get("Authorization"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsernamePasswordProvider_LoginFailure(t *testing.T) {
|
|
||||||
// Mock login server that returns error
|
|
||||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
_, _ = w.Write([]byte("Invalid credentials"))
|
|
||||||
}))
|
|
||||||
defer loginServer.Close()
|
|
||||||
|
|
||||||
provider := NewUsernamePasswordProvider(loginServer.URL, "baduser", "badpass", nil)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "login failed with status 401")
|
|
||||||
assert.Contains(t, err.Error(), "Invalid credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsernamePasswordProvider_TokenCaching(t *testing.T) {
|
|
||||||
callCount := 0
|
|
||||||
|
|
||||||
// Mock login server that tracks calls
|
|
||||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
callCount++
|
|
||||||
response := map[string]string{"token": "cached-token-789"}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(response)
|
|
||||||
}))
|
|
||||||
defer loginServer.Close()
|
|
||||||
|
|
||||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// First request should call login
|
|
||||||
req1, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
err1 := provider.Attach(ctx, req1)
|
|
||||||
require.NoError(t, err1)
|
|
||||||
assert.Equal(t, "Bearer cached-token-789", req1.Header.Get("Authorization"))
|
|
||||||
assert.Equal(t, 1, callCount)
|
|
||||||
|
|
||||||
// Second request should use cached token (no additional login call)
|
|
||||||
req2, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
err2 := provider.Attach(ctx, req2)
|
|
||||||
require.NoError(t, err2)
|
|
||||||
assert.Equal(t, "Bearer cached-token-789", req2.Header.Get("Authorization"))
|
|
||||||
assert.Equal(t, 1, callCount) // Still only 1 call
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) {
|
|
||||||
callCount := 0
|
|
||||||
|
|
||||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
callCount++
|
|
||||||
response := map[string]string{"token": "refreshed-token-999"}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(response)
|
|
||||||
}))
|
|
||||||
defer loginServer.Close()
|
|
||||||
|
|
||||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
|
||||||
|
|
||||||
// Manually set expired token
|
|
||||||
provider.mu.Lock()
|
|
||||||
provider.cachedToken = "expired-token"
|
|
||||||
provider.tokenExpiry = time.Now().Add(-1 * time.Hour) // Already expired
|
|
||||||
provider.mu.Unlock()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "Bearer refreshed-token-999", req.Header.Get("Authorization"))
|
|
||||||
assert.Equal(t, 1, callCount) // New token retrieved
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) {
|
|
||||||
callCount := 0
|
|
||||||
|
|
||||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
callCount++
|
|
||||||
response := map[string]string{"token": "new-token-after-invalidation"}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(response)
|
|
||||||
}))
|
|
||||||
defer loginServer.Close()
|
|
||||||
|
|
||||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// First request to get token
|
|
||||||
req1, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
err1 := provider.Attach(ctx, req1)
|
|
||||||
require.NoError(t, err1)
|
|
||||||
assert.Equal(t, 1, callCount)
|
|
||||||
|
|
||||||
// Invalidate token
|
|
||||||
provider.InvalidateToken()
|
|
||||||
|
|
||||||
// Next request should get new token
|
|
||||||
req2, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
err2 := provider.Attach(ctx, req2)
|
|
||||||
require.NoError(t, err2)
|
|
||||||
assert.Equal(t, "Bearer new-token-after-invalidation", req2.Header.Get("Authorization"))
|
|
||||||
assert.Equal(t, 2, callCount) // New login call made
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsernamePasswordProvider_BadJSONResponse(t *testing.T) {
|
|
||||||
// Mock server returning invalid JSON
|
|
||||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte("invalid json response"))
|
|
||||||
}))
|
|
||||||
defer loginServer.Close()
|
|
||||||
|
|
||||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "error parsing JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoAuthProvider(t *testing.T) {
|
|
||||||
provider := NewNoAuthProvider()
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := provider.Attach(ctx, req)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, req.Header.Get("Authorization"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewClientWithCredentials(t *testing.T) {
|
|
||||||
client := NewClientWithCredentials("https://example.com", "testuser", "testpass")
|
|
||||||
|
|
||||||
assert.Equal(t, "https://example.com", client.BaseURL)
|
|
||||||
|
|
||||||
// Check that auth provider is UsernamePasswordProvider
|
|
||||||
authProvider, ok := client.AuthProvider.(*UsernamePasswordProvider)
|
|
||||||
require.True(t, ok, "AuthProvider should be UsernamePasswordProvider")
|
|
||||||
assert.Equal(t, "testuser", authProvider.Username)
|
|
||||||
assert.Equal(t, "testpass", authProvider.Password)
|
|
||||||
assert.Equal(t, "https://example.com", authProvider.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth
|
|
||||||
// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations
|
|
||||||
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client represents the EdgeXR Master Controller SDK client
|
|
||||||
type Client struct {
|
|
||||||
BaseURL string
|
|
||||||
HTTPClient *http.Client
|
|
||||||
AuthProvider AuthProvider
|
|
||||||
RetryOpts RetryOptions
|
|
||||||
Logger Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// RetryOptions configures retry behavior for API calls
|
|
||||||
type RetryOptions struct {
|
|
||||||
MaxRetries int
|
|
||||||
InitialDelay time.Duration
|
|
||||||
MaxDelay time.Duration
|
|
||||||
Multiplier float64
|
|
||||||
RetryableHTTPStatusCodes []int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logger interface for optional logging
|
|
||||||
type Logger interface {
|
|
||||||
Printf(format string, v ...interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultRetryOptions returns sensible default retry configuration
|
|
||||||
func DefaultRetryOptions() RetryOptions {
|
|
||||||
return RetryOptions{
|
|
||||||
MaxRetries: 3,
|
|
||||||
InitialDelay: 1 * time.Second,
|
|
||||||
MaxDelay: 30 * time.Second,
|
|
||||||
Multiplier: 2.0,
|
|
||||||
RetryableHTTPStatusCodes: []int{
|
|
||||||
http.StatusRequestTimeout,
|
|
||||||
http.StatusTooManyRequests,
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
http.StatusBadGateway,
|
|
||||||
http.StatusServiceUnavailable,
|
|
||||||
http.StatusGatewayTimeout,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option represents a configuration option for the client
|
|
||||||
type Option func(*Client)
|
|
||||||
|
|
||||||
// WithHTTPClient sets a custom HTTP client
|
|
||||||
func WithHTTPClient(client *http.Client) Option {
|
|
||||||
return func(c *Client) {
|
|
||||||
c.HTTPClient = client
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithAuthProvider sets the authentication provider
|
|
||||||
func WithAuthProvider(auth AuthProvider) Option {
|
|
||||||
return func(c *Client) {
|
|
||||||
c.AuthProvider = auth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithRetryOptions sets retry configuration
|
|
||||||
func WithRetryOptions(opts RetryOptions) Option {
|
|
||||||
return func(c *Client) {
|
|
||||||
c.RetryOpts = opts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithLogger sets a logger for debugging
|
|
||||||
func WithLogger(logger Logger) Option {
|
|
||||||
return func(c *Client) {
|
|
||||||
c.Logger = logger
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient creates a new EdgeXR SDK client
|
|
||||||
func NewClient(baseURL string, options ...Option) *Client {
|
|
||||||
client := &Client{
|
|
||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
||||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
AuthProvider: NewNoAuthProvider(),
|
|
||||||
RetryOpts: DefaultRetryOptions(),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range options {
|
|
||||||
opt(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClientWithCredentials creates a new EdgeXR SDK client with username/password authentication
|
|
||||||
// This matches the existing client pattern from client/client.go
|
|
||||||
func NewClientWithCredentials(baseURL, username, password string, options ...Option) *Client {
|
|
||||||
client := &Client{
|
|
||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
||||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
AuthProvider: NewUsernamePasswordProvider(baseURL, username, password, nil),
|
|
||||||
RetryOpts: DefaultRetryOptions(),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range options {
|
|
||||||
opt(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
// logf logs a message if a logger is configured
|
|
||||||
func (c *Client) logf(format string, v ...interface{}) {
|
|
||||||
if c.Logger != nil {
|
|
||||||
c.Logger.Printf(format, v...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
// ABOUTME: Cloudlet management APIs for EdgeXR Master Controller
|
|
||||||
// ABOUTME: Provides typed methods for creating, querying, and managing edge cloudlets
|
|
||||||
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateCloudlet creates a new cloudlet in the specified region
|
|
||||||
// Maps to POST /auth/ctrl/CreateCloudlet
|
|
||||||
func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/CreateCloudlet"
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("CreateCloudlet failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return c.handleErrorResponse(resp, "CreateCloudlet")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("CreateCloudlet: %s/%s created successfully",
|
|
||||||
input.Cloudlet.Key.Organization, input.Cloudlet.Key.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowCloudlet retrieves a single cloudlet by key and region
|
|
||||||
// Maps to POST /auth/ctrl/ShowCloudlet
|
|
||||||
func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) (Cloudlet, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet"
|
|
||||||
|
|
||||||
filter := CloudletFilter{
|
|
||||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return Cloudlet{}, c.handleErrorResponse(resp, "ShowCloudlet")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse streaming JSON response
|
|
||||||
var cloudlets []Cloudlet
|
|
||||||
if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil {
|
|
||||||
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cloudlets) == 0 {
|
|
||||||
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cloudlets[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowCloudlets retrieves all cloudlets matching the filter criteria
|
|
||||||
// Maps to POST /auth/ctrl/ShowCloudlet
|
|
||||||
func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, region string) ([]Cloudlet, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet"
|
|
||||||
|
|
||||||
filter := CloudletFilter{
|
|
||||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ShowCloudlets failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
|
||||||
return nil, c.handleErrorResponse(resp, "ShowCloudlets")
|
|
||||||
}
|
|
||||||
|
|
||||||
var cloudlets []Cloudlet
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return cloudlets, nil // Return empty slice for not found
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil {
|
|
||||||
return nil, fmt.Errorf("ShowCloudlets failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets))
|
|
||||||
return cloudlets, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteCloudlet removes a cloudlet from the specified region
|
|
||||||
// Maps to POST /auth/ctrl/DeleteCloudlet
|
|
||||||
func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) error {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteCloudlet"
|
|
||||||
|
|
||||||
filter := CloudletFilter{
|
|
||||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("DeleteCloudlet failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 404 is acceptable for delete operations (already deleted)
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
|
||||||
return c.handleErrorResponse(resp, "DeleteCloudlet")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("DeleteCloudlet: %s/%s deleted successfully",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCloudletManifest retrieves the deployment manifest for a cloudlet
|
|
||||||
// Maps to POST /auth/ctrl/GetCloudletManifest
|
|
||||||
func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletManifest, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletManifest"
|
|
||||||
|
|
||||||
filter := CloudletFilter{
|
|
||||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("GetCloudletManifest failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return nil, c.handleErrorResponse(resp, "GetCloudletManifest")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the response as CloudletManifest
|
|
||||||
var manifest CloudletManifest
|
|
||||||
if err := c.parseDirectJSONResponse(resp, &manifest); err != nil {
|
|
||||||
return nil, fmt.Errorf("GetCloudletManifest failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("GetCloudletManifest: retrieved manifest for %s/%s",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name)
|
|
||||||
|
|
||||||
return &manifest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCloudletResourceUsage retrieves resource usage information for a cloudlet
|
|
||||||
// Maps to POST /auth/ctrl/GetCloudletResourceUsage
|
|
||||||
func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletResourceUsage, error) {
|
|
||||||
transport := c.getTransport()
|
|
||||||
url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletResourceUsage"
|
|
||||||
|
|
||||||
filter := CloudletFilter{
|
|
||||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
|
||||||
Region: region,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return nil, c.handleErrorResponse(resp, "GetCloudletResourceUsage")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the response as CloudletResourceUsage
|
|
||||||
var usage CloudletResourceUsage
|
|
||||||
if err := c.parseDirectJSONResponse(resp, &usage); err != nil {
|
|
||||||
return nil, fmt.Errorf("GetCloudletResourceUsage failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logf("GetCloudletResourceUsage: retrieved usage for %s/%s",
|
|
||||||
cloudletKey.Organization, cloudletKey.Name)
|
|
||||||
|
|
||||||
return &usage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseStreamingCloudletResponse parses the EdgeXR streaming JSON response format for cloudlets
|
|
||||||
func (c *Client) parseStreamingCloudletResponse(resp *http.Response, result interface{}) error {
|
|
||||||
var responses []Response[Cloudlet]
|
|
||||||
|
|
||||||
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
|
|
||||||
var response Response[Cloudlet]
|
|
||||||
if err := json.Unmarshal(line, &response); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
responses = append(responses, response)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if parseErr != nil {
|
|
||||||
return parseErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract data from responses
|
|
||||||
var cloudlets []Cloudlet
|
|
||||||
var messages []string
|
|
||||||
|
|
||||||
for _, response := range responses {
|
|
||||||
if response.HasData() {
|
|
||||||
cloudlets = append(cloudlets, response.Data)
|
|
||||||
}
|
|
||||||
if response.IsMessage() {
|
|
||||||
messages = append(messages, response.Data.GetMessage())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have error messages, return them
|
|
||||||
if len(messages) > 0 {
|
|
||||||
return &APIError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Messages: messages,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set result based on type
|
|
||||||
switch v := result.(type) {
|
|
||||||
case *[]Cloudlet:
|
|
||||||
*v = cloudlets
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported result type: %T", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDirectJSONResponse parses a direct JSON response (not streaming)
|
|
||||||
func (c *Client) parseDirectJSONResponse(resp *http.Response, result interface{}) error {
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
if err := decoder.Decode(result); err != nil {
|
|
||||||
return fmt.Errorf("failed to decode JSON response: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,408 +0,0 @@
|
||||||
// ABOUTME: Unit tests for Cloudlet management APIs using httptest mock server
|
|
||||||
// ABOUTME: Tests create, show, list, delete, manifest, and resource usage operations
|
|
||||||
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateCloudlet(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input *NewCloudletInput
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful creation",
|
|
||||||
input: &NewCloudletInput{
|
|
||||||
Region: "us-west",
|
|
||||||
Cloudlet: Cloudlet{
|
|
||||||
Key: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
Location: Location{
|
|
||||||
Latitude: 37.7749,
|
|
||||||
Longitude: -122.4194,
|
|
||||||
},
|
|
||||||
IpSupport: "IpSupportDynamic",
|
|
||||||
NumDynamicIps: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"message": "success"}`,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "validation error",
|
|
||||||
input: &NewCloudletInput{
|
|
||||||
Region: "us-west",
|
|
||||||
Cloudlet: Cloudlet{
|
|
||||||
Key: CloudletKey{
|
|
||||||
Organization: "",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mockStatusCode: 400,
|
|
||||||
mockResponse: `{"message": "organization is required"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/CreateCloudlet", r.URL.Path)
|
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
err := client.CreateCloudlet(ctx, tt.input)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShowCloudlet(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cloudletKey CloudletKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
expectNotFound bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful show",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"data": {"key": {"organization": "cloudletorg", "name": "testcloudlet"}, "state": "Ready", "location": {"latitude": 37.7749, "longitude": -122.4194}}}
|
|
||||||
`,
|
|
||||||
expectError: false,
|
|
||||||
expectNotFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cloudlet not found",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: "",
|
|
||||||
expectError: true,
|
|
||||||
expectNotFound: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create mock server
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
if tt.mockResponse != "" {
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client := NewClient(server.URL,
|
|
||||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Execute test
|
|
||||||
ctx := context.Background()
|
|
||||||
cloudlet, err := client.ShowCloudlet(ctx, tt.cloudletKey, tt.region)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.expectNotFound {
|
|
||||||
assert.Contains(t, err.Error(), "resource not found")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.cloudletKey.Organization, cloudlet.Key.Organization)
|
|
||||||
assert.Equal(t, tt.cloudletKey.Name, cloudlet.Key.Name)
|
|
||||||
assert.Equal(t, "Ready", cloudlet.State)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShowCloudlets(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path)
|
|
||||||
|
|
||||||
// Verify request body
|
|
||||||
var filter CloudletFilter
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&filter)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "cloudletorg", filter.Cloudlet.Key.Organization)
|
|
||||||
assert.Equal(t, "us-west", filter.Region)
|
|
||||||
|
|
||||||
// Return multiple cloudlets
|
|
||||||
response := `{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet1"}, "state": "Ready"}}
|
|
||||||
{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}}
|
|
||||||
`
|
|
||||||
w.WriteHeader(200)
|
|
||||||
_, _ = w.Write([]byte(response))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
cloudlets, err := client.ShowCloudlets(ctx, CloudletKey{Organization: "cloudletorg"}, "us-west")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, cloudlets, 2)
|
|
||||||
assert.Equal(t, "cloudlet1", cloudlets[0].Key.Name)
|
|
||||||
assert.Equal(t, "Ready", cloudlets[0].State)
|
|
||||||
assert.Equal(t, "cloudlet2", cloudlets[1].Key.Name)
|
|
||||||
assert.Equal(t, "Creating", cloudlets[1].State)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteCloudlet(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cloudletKey CloudletKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful deletion",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "already deleted (404 ok)",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "server error",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 500,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/DeleteCloudlet", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := client.DeleteCloudlet(ctx, tt.cloudletKey, tt.region)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetCloudletManifest(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cloudletKey CloudletKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
expectNotFound bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful manifest retrieval",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"manifest": "apiVersion: v1\nkind: Deployment\nmetadata:\n name: test", "last_modified": "2024-01-01T00:00:00Z"}`,
|
|
||||||
expectError: false,
|
|
||||||
expectNotFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "manifest not found",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: "",
|
|
||||||
expectError: true,
|
|
||||||
expectNotFound: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletManifest", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
if tt.mockResponse != "" {
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
manifest, err := client.GetCloudletManifest(ctx, tt.cloudletKey, tt.region)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.expectNotFound {
|
|
||||||
assert.Contains(t, err.Error(), "resource not found")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotNil(t, manifest)
|
|
||||||
assert.Contains(t, manifest.Manifest, "apiVersion: v1")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetCloudletResourceUsage(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cloudletKey CloudletKey
|
|
||||||
region string
|
|
||||||
mockStatusCode int
|
|
||||||
mockResponse string
|
|
||||||
expectError bool
|
|
||||||
expectNotFound bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful usage retrieval",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "testcloudlet",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 200,
|
|
||||||
mockResponse: `{"cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}, "region": "us-west", "usage": {"cpu": "50%", "memory": "30%", "disk": "20%"}}`,
|
|
||||||
expectError: false,
|
|
||||||
expectNotFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "usage not found",
|
|
||||||
cloudletKey: CloudletKey{
|
|
||||||
Organization: "cloudletorg",
|
|
||||||
Name: "nonexistent",
|
|
||||||
},
|
|
||||||
region: "us-west",
|
|
||||||
mockStatusCode: 404,
|
|
||||||
mockResponse: "",
|
|
||||||
expectError: true,
|
|
||||||
expectNotFound: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "POST", r.Method)
|
|
||||||
assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletResourceUsage", r.URL.Path)
|
|
||||||
|
|
||||||
w.WriteHeader(tt.mockStatusCode)
|
|
||||||
if tt.mockResponse != "" {
|
|
||||||
_, _ = w.Write([]byte(tt.mockResponse))
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := NewClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
usage, err := client.GetCloudletResourceUsage(ctx, tt.cloudletKey, tt.region)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
if tt.expectNotFound {
|
|
||||||
assert.Contains(t, err.Error(), "resource not found")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotNil(t, usage)
|
|
||||||
assert.Equal(t, "cloudletorg", usage.CloudletKey.Organization)
|
|
||||||
assert.Equal(t, "testcloudlet", usage.CloudletKey.Name)
|
|
||||||
assert.Equal(t, "us-west", usage.Region)
|
|
||||||
assert.Contains(t, usage.Usage, "cpu")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,421 +0,0 @@
|
||||||
// ABOUTME: Core type definitions for EdgeXR Master Controller SDK
|
|
||||||
// ABOUTME: These types are based on the swagger API specification and existing client patterns
|
|
||||||
|
|
||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// App field constants for partial updates (based on EdgeXR API specification)
|
|
||||||
const (
|
|
||||||
AppFieldKey = "2"
|
|
||||||
AppFieldKeyOrganization = "2.1"
|
|
||||||
AppFieldKeyName = "2.2"
|
|
||||||
AppFieldKeyVersion = "2.3"
|
|
||||||
AppFieldImagePath = "4"
|
|
||||||
AppFieldImageType = "5"
|
|
||||||
AppFieldAccessPorts = "7"
|
|
||||||
AppFieldDefaultFlavor = "9"
|
|
||||||
AppFieldDefaultFlavorName = "9.1"
|
|
||||||
AppFieldAuthPublicKey = "12"
|
|
||||||
AppFieldCommand = "13"
|
|
||||||
AppFieldAnnotations = "14"
|
|
||||||
AppFieldDeployment = "15"
|
|
||||||
AppFieldDeploymentManifest = "16"
|
|
||||||
AppFieldDeploymentGenerator = "17"
|
|
||||||
AppFieldAndroidPackageName = "18"
|
|
||||||
AppFieldDelOpt = "20"
|
|
||||||
AppFieldConfigs = "21"
|
|
||||||
AppFieldConfigsKind = "21.1"
|
|
||||||
AppFieldConfigsConfig = "21.2"
|
|
||||||
AppFieldScaleWithCluster = "22"
|
|
||||||
AppFieldInternalPorts = "23"
|
|
||||||
AppFieldRevision = "24"
|
|
||||||
AppFieldOfficialFqdn = "25"
|
|
||||||
AppFieldMd5Sum = "26"
|
|
||||||
AppFieldAutoProvPolicy = "28"
|
|
||||||
AppFieldAccessType = "29"
|
|
||||||
AppFieldDeletePrepare = "31"
|
|
||||||
AppFieldAutoProvPolicies = "32"
|
|
||||||
AppFieldTemplateDelimiter = "33"
|
|
||||||
AppFieldSkipHcPorts = "34"
|
|
||||||
AppFieldCreatedAt = "35"
|
|
||||||
AppFieldCreatedAtSeconds = "35.1"
|
|
||||||
AppFieldCreatedAtNanos = "35.2"
|
|
||||||
AppFieldUpdatedAt = "36"
|
|
||||||
AppFieldUpdatedAtSeconds = "36.1"
|
|
||||||
AppFieldUpdatedAtNanos = "36.2"
|
|
||||||
AppFieldTrusted = "37"
|
|
||||||
AppFieldRequiredOutboundConnections = "38"
|
|
||||||
AppFieldAllowServerless = "39"
|
|
||||||
AppFieldServerlessConfig = "40"
|
|
||||||
AppFieldVmAppOsType = "41"
|
|
||||||
AppFieldAlertPolicies = "42"
|
|
||||||
AppFieldQosSessionProfile = "43"
|
|
||||||
AppFieldQosSessionDuration = "44"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AppInstance field constants for partial updates (based on EdgeXR API specification)
|
|
||||||
const (
|
|
||||||
AppInstFieldKey = "2"
|
|
||||||
AppInstFieldKeyAppKey = "2.1"
|
|
||||||
AppInstFieldKeyAppKeyOrganization = "2.1.1"
|
|
||||||
AppInstFieldKeyAppKeyName = "2.1.2"
|
|
||||||
AppInstFieldKeyAppKeyVersion = "2.1.3"
|
|
||||||
AppInstFieldKeyClusterInstKey = "2.4"
|
|
||||||
AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1"
|
|
||||||
AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1"
|
|
||||||
AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2"
|
|
||||||
AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1"
|
|
||||||
AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2"
|
|
||||||
AppInstFieldKeyClusterInstKeyCloudletKeyFederatedOrganization = "2.4.2.3"
|
|
||||||
AppInstFieldKeyClusterInstKeyOrganization = "2.4.3"
|
|
||||||
AppInstFieldCloudletLoc = "3"
|
|
||||||
AppInstFieldCloudletLocLatitude = "3.1"
|
|
||||||
AppInstFieldCloudletLocLongitude = "3.2"
|
|
||||||
AppInstFieldCloudletLocHorizontalAccuracy = "3.3"
|
|
||||||
AppInstFieldCloudletLocVerticalAccuracy = "3.4"
|
|
||||||
AppInstFieldCloudletLocAltitude = "3.5"
|
|
||||||
AppInstFieldCloudletLocCourse = "3.6"
|
|
||||||
AppInstFieldCloudletLocSpeed = "3.7"
|
|
||||||
AppInstFieldCloudletLocTimestamp = "3.8"
|
|
||||||
AppInstFieldCloudletLocTimestampSeconds = "3.8.1"
|
|
||||||
AppInstFieldCloudletLocTimestampNanos = "3.8.2"
|
|
||||||
AppInstFieldUri = "4"
|
|
||||||
AppInstFieldLiveness = "6"
|
|
||||||
AppInstFieldMappedPorts = "9"
|
|
||||||
AppInstFieldMappedPortsProto = "9.1"
|
|
||||||
AppInstFieldMappedPortsInternalPort = "9.2"
|
|
||||||
AppInstFieldMappedPortsPublicPort = "9.3"
|
|
||||||
AppInstFieldMappedPortsFqdnPrefix = "9.5"
|
|
||||||
AppInstFieldMappedPortsEndPort = "9.6"
|
|
||||||
AppInstFieldMappedPortsTls = "9.7"
|
|
||||||
AppInstFieldMappedPortsNginx = "9.8"
|
|
||||||
AppInstFieldMappedPortsMaxPktSize = "9.9"
|
|
||||||
AppInstFieldFlavor = "12"
|
|
||||||
AppInstFieldFlavorName = "12.1"
|
|
||||||
AppInstFieldState = "14"
|
|
||||||
AppInstFieldErrors = "15"
|
|
||||||
AppInstFieldCrmOverride = "16"
|
|
||||||
AppInstFieldRuntimeInfo = "17"
|
|
||||||
AppInstFieldRuntimeInfoContainerIds = "17.1"
|
|
||||||
AppInstFieldCreatedAt = "21"
|
|
||||||
AppInstFieldCreatedAtSeconds = "21.1"
|
|
||||||
AppInstFieldCreatedAtNanos = "21.2"
|
|
||||||
AppInstFieldAutoClusterIpAccess = "22"
|
|
||||||
AppInstFieldRevision = "24"
|
|
||||||
AppInstFieldForceUpdate = "25"
|
|
||||||
AppInstFieldUpdateMultiple = "26"
|
|
||||||
AppInstFieldConfigs = "27"
|
|
||||||
AppInstFieldConfigsKind = "27.1"
|
|
||||||
AppInstFieldConfigsConfig = "27.2"
|
|
||||||
AppInstFieldHealthCheck = "29"
|
|
||||||
AppInstFieldPowerState = "31"
|
|
||||||
AppInstFieldExternalVolumeSize = "32"
|
|
||||||
AppInstFieldAvailabilityZone = "33"
|
|
||||||
AppInstFieldVmFlavor = "34"
|
|
||||||
AppInstFieldOptRes = "35"
|
|
||||||
AppInstFieldUpdatedAt = "36"
|
|
||||||
AppInstFieldUpdatedAtSeconds = "36.1"
|
|
||||||
AppInstFieldUpdatedAtNanos = "36.2"
|
|
||||||
AppInstFieldRealClusterName = "37"
|
|
||||||
AppInstFieldInternalPortToLbIp = "38"
|
|
||||||
AppInstFieldInternalPortToLbIpKey = "38.1"
|
|
||||||
AppInstFieldInternalPortToLbIpValue = "38.2"
|
|
||||||
AppInstFieldDedicatedIp = "39"
|
|
||||||
AppInstFieldUniqueId = "40"
|
|
||||||
AppInstFieldDnsLabel = "41"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Message interface for types that can provide error messages
|
|
||||||
type Message interface {
|
|
||||||
GetMessage() string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base message type for API responses
|
|
||||||
type msg struct {
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m msg) GetMessage() string {
|
|
||||||
return m.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppKey uniquely identifies an application
|
|
||||||
type AppKey struct {
|
|
||||||
Organization string `json:"organization"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Version string `json:"version,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloudletKey uniquely identifies a cloudlet
|
|
||||||
type CloudletKey struct {
|
|
||||||
Organization string `json:"organization"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppInstanceKey uniquely identifies an application instance
|
|
||||||
type AppInstanceKey struct {
|
|
||||||
Organization string `json:"organization"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
CloudletKey CloudletKey `json:"cloudlet_key"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flavor defines resource allocation for instances
|
|
||||||
type Flavor struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecurityRule defines network access rules
|
|
||||||
type SecurityRule struct {
|
|
||||||
PortRangeMax int `json:"port_range_max"`
|
|
||||||
PortRangeMin int `json:"port_range_min"`
|
|
||||||
Protocol string `json:"protocol"`
|
|
||||||
RemoteCIDR string `json:"remote_cidr"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// App represents an application definition
|
|
||||||
type App struct {
|
|
||||||
msg `json:",inline"`
|
|
||||||
Key AppKey `json:"key"`
|
|
||||||
Deployment string `json:"deployment,omitempty"`
|
|
||||||
ImageType string `json:"image_type,omitempty"`
|
|
||||||
ImagePath string `json:"image_path,omitempty"`
|
|
||||||
AccessPorts string `json:"access_ports,omitempty"`
|
|
||||||
AllowServerless bool `json:"allow_serverless,omitempty"`
|
|
||||||
DefaultFlavor Flavor `json:"defaultFlavor,omitempty"`
|
|
||||||
ServerlessConfig interface{} `json:"serverless_config,omitempty"`
|
|
||||||
DeploymentGenerator string `json:"deployment_generator,omitempty"`
|
|
||||||
DeploymentManifest string `json:"deployment_manifest,omitempty"`
|
|
||||||
RequiredOutboundConnections []SecurityRule `json:"required_outbound_connections"`
|
|
||||||
GlobalID string `json:"global_id,omitempty"`
|
|
||||||
CreatedAt string `json:"created_at,omitempty"`
|
|
||||||
UpdatedAt string `json:"updated_at,omitempty"`
|
|
||||||
Fields []string `json:"fields,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppInstance represents a deployed application instance
|
|
||||||
type AppInstance struct {
|
|
||||||
msg `json:",inline"`
|
|
||||||
Key AppInstanceKey `json:"key"`
|
|
||||||
AppKey AppKey `json:"app_key,omitempty"`
|
|
||||||
CloudletLoc CloudletLoc `json:"cloudlet_loc,omitempty"`
|
|
||||||
Flavor Flavor `json:"flavor,omitempty"`
|
|
||||||
State string `json:"state,omitempty"`
|
|
||||||
IngressURL string `json:"ingress_url,omitempty"`
|
|
||||||
UniqueID string `json:"unique_id,omitempty"`
|
|
||||||
CreatedAt string `json:"created_at,omitempty"`
|
|
||||||
UpdatedAt string `json:"updated_at,omitempty"`
|
|
||||||
PowerState string `json:"power_state,omitempty"`
|
|
||||||
Fields []string `json:"fields,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cloudlet represents edge infrastructure
|
|
||||||
type Cloudlet struct {
|
|
||||||
msg `json:",inline"`
|
|
||||||
Key CloudletKey `json:"key"`
|
|
||||||
Location Location `json:"location"`
|
|
||||||
IpSupport string `json:"ip_support,omitempty"`
|
|
||||||
NumDynamicIps int32 `json:"num_dynamic_ips,omitempty"`
|
|
||||||
State string `json:"state,omitempty"`
|
|
||||||
Flavor Flavor `json:"flavor,omitempty"`
|
|
||||||
PhysicalName string `json:"physical_name,omitempty"`
|
|
||||||
Region string `json:"region,omitempty"`
|
|
||||||
NotifySrvAddr string `json:"notify_srv_addr,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Location represents geographical coordinates
|
|
||||||
type Location struct {
|
|
||||||
Latitude float64 `json:"latitude"`
|
|
||||||
Longitude float64 `json:"longitude"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloudletLoc represents geographical coordinates for cloudlets
|
|
||||||
type CloudletLoc struct {
|
|
||||||
Latitude float64 `json:"latitude"`
|
|
||||||
Longitude float64 `json:"longitude"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input types for API operations
|
|
||||||
|
|
||||||
// NewAppInput represents input for creating an application
|
|
||||||
type NewAppInput struct {
|
|
||||||
Region string `json:"region"`
|
|
||||||
App App `json:"app"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAppInstanceInput represents input for creating an app instance
|
|
||||||
type NewAppInstanceInput struct {
|
|
||||||
Region string `json:"region"`
|
|
||||||
AppInst AppInstance `json:"appinst"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCloudletInput represents input for creating a cloudlet
|
|
||||||
type NewCloudletInput struct {
|
|
||||||
Region string `json:"region"`
|
|
||||||
Cloudlet Cloudlet `json:"cloudlet"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateAppInput represents input for updating an application
|
|
||||||
type UpdateAppInput struct {
|
|
||||||
Region string `json:"region"`
|
|
||||||
App App `json:"app"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateAppInstanceInput represents input for updating an app instance
|
|
||||||
type UpdateAppInstanceInput struct {
|
|
||||||
Region string `json:"region"`
|
|
||||||
AppInst AppInstance `json:"appinst"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteAppInput represents input for deleting an application
|
|
||||||
type DeleteAppInput struct {
|
|
||||||
Region string `json:"region"`
|
|
||||||
App struct {
|
|
||||||
Key AppKey `json:"key"`
|
|
||||||
} `json:"app"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteAppInstanceInput represents input for deleting an app instance
|
|
||||||
type DeleteAppInstanceInput struct {
|
|
||||||
Region string `json:"region"`
|
|
||||||
AppInst struct {
|
|
||||||
Key AppInstanceKey `json:"key"`
|
|
||||||
} `json:"appinst"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response wrapper types
|
|
||||||
|
|
||||||
// Response wraps a single API response
|
|
||||||
type Response[T Message] struct {
|
|
||||||
ResultResponse `json:",inline"`
|
|
||||||
Data T `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (res *Response[T]) HasData() bool {
|
|
||||||
return !res.IsMessage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (res *Response[T]) IsMessage() bool {
|
|
||||||
return res.Data.GetMessage() != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResultResponse represents an API result with error code
|
|
||||||
type ResultResponse struct {
|
|
||||||
Result struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
Code int `json:"code"`
|
|
||||||
} `json:"result"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ResultResponse) IsError() bool {
|
|
||||||
return r.Result.Code >= 400
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ResultResponse) GetMessage() string {
|
|
||||||
return r.Result.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ResultResponse) GetCode() int {
|
|
||||||
return r.Result.Code
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responses wraps multiple API responses with metadata
|
|
||||||
type Responses[T Message] struct {
|
|
||||||
Responses []Response[T] `json:"responses,omitempty"`
|
|
||||||
StatusCode int `json:"-"`
|
|
||||||
Errors []error `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Responses[T]) GetData() []T {
|
|
||||||
var data []T
|
|
||||||
for _, v := range r.Responses {
|
|
||||||
if v.HasData() {
|
|
||||||
data = append(data, v.Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Responses[T]) GetMessages() []string {
|
|
||||||
var messages []string
|
|
||||||
for _, v := range r.Responses {
|
|
||||||
if v.IsMessage() {
|
|
||||||
messages = append(messages, v.Data.GetMessage())
|
|
||||||
}
|
|
||||||
if v.Result.Message != "" {
|
|
||||||
messages = append(messages, v.Result.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return messages
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Responses[T]) IsSuccessful() bool {
|
|
||||||
return len(r.Errors) == 0 && (r.StatusCode >= 200 && r.StatusCode < 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Responses[T]) Error() error {
|
|
||||||
if r.IsSuccessful() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &APIError{
|
|
||||||
StatusCode: r.StatusCode,
|
|
||||||
Messages: r.GetMessages(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// APIError represents an API error with details
|
|
||||||
type APIError struct {
|
|
||||||
StatusCode int `json:"status_code"`
|
|
||||||
Code string `json:"code,omitempty"`
|
|
||||||
Messages []string `json:"messages,omitempty"`
|
|
||||||
Body []byte `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *APIError) Error() string {
|
|
||||||
jsonErr, err := json.Marshal(e)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Sprintf("API error: %v", err)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("API error: %s", jsonErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter types for querying
|
|
||||||
|
|
||||||
// AppFilter represents filters for app queries
|
|
||||||
type AppFilter struct {
|
|
||||||
App App `json:"app"`
|
|
||||||
Region string `json:"region"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppInstanceFilter represents filters for app instance queries
|
|
||||||
type AppInstanceFilter struct {
|
|
||||||
AppInstance AppInstance `json:"appinst"`
|
|
||||||
Region string `json:"region"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloudletFilter represents filters for cloudlet queries
|
|
||||||
type CloudletFilter struct {
|
|
||||||
Cloudlet Cloudlet `json:"cloudlet"`
|
|
||||||
Region string `json:"region"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloudletManifest represents cloudlet deployment manifest
|
|
||||||
type CloudletManifest struct {
|
|
||||||
Manifest string `json:"manifest"`
|
|
||||||
LastModified time.Time `json:"last_modified,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloudletResourceUsage represents cloudlet resource utilization
|
|
||||||
type CloudletResourceUsage struct {
|
|
||||||
CloudletKey CloudletKey `json:"cloudlet_key"`
|
|
||||||
Region string `json:"region"`
|
|
||||||
Usage map[string]interface{} `json:"usage"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ErrorMessage struct {
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue