Compare commits
39 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02856be541 | |||
| e38d7e84d5 | |||
| 2909e0d1b4 | |||
| ece2955a2a | |||
| a51e2ae454 | |||
| ece3dddfe6 | |||
| 9772a072e8 | |||
| f3cbfa3723 | |||
| 26ba07200e | |||
| 716c8e79e4 | |||
| 9cb9f97a1f | |||
| 65e0185064 | |||
| 318af7baff | |||
| a70e107a3f | |||
| df697c0ff6 | |||
| f921169351 | |||
| 98a8c4db4a | |||
| 59ba5ffb02 | |||
| 2a8e99eb63 | |||
| 3486b2228d | |||
| 1413836b68 | |||
| 0f71239db6 | |||
| dbf7ccb0d6 | |||
| 5f54082813 | |||
| 0b31409b26 | |||
| 8f6fd94442 | |||
| 4ded2e193e | |||
| c7b1284606 | |||
| 921822239b | |||
| f32479aaf8 | |||
| a72341356b | |||
| bc524c3b0e | |||
| 0f3cc90b01 | |||
| 06f921963a | |||
| cc8b9e791b | |||
| f635157d67 | |||
| e092f352f8 | |||
| 6de170f6cf | |||
| 393977c7fc |
78 changed files with 11086 additions and 563 deletions
14
.edge-connect.yaml.example
Normal file
14
.edge-connect.yaml.example
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Example EdgeConnect CLI Configuration File
|
||||
# Place this file at ~/.edge-connect.yaml or specify with --config flag
|
||||
|
||||
# Base URL for the EdgeConnect API
|
||||
base_url: "https://hub.apps.edge.platform.mg3.mdb.osc.live"
|
||||
|
||||
# Authentication credentials
|
||||
username: "your-username@example.com"
|
||||
password: "your-password"
|
||||
|
||||
# API version to use (v1 or v2)
|
||||
# Default: v2
|
||||
# Set via config, --api-version flag, or EDGE_CONNECT_API_VERSION env var
|
||||
api_version: "v2"
|
||||
1
.envrc.example
Normal file
1
.envrc.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
use flake
|
||||
7
.github/workflows/release.yaml
vendored
7
.github/workflows/release.yaml
vendored
|
|
@ -19,9 +19,16 @@ jobs:
|
|||
go-version: ">=1.25.1"
|
||||
- name: Test code
|
||||
run: make test
|
||||
- name: Import GPG key
|
||||
id: import_gpg
|
||||
uses: https://github.com/crazy-max/ghaction-import-gpg@v6
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
- name: Run GoReleaser
|
||||
uses: https://github.com/goreleaser/goreleaser-action@v6
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
|
||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||
with:
|
||||
args: release --clean
|
||||
|
|
|
|||
23
.github/workflows/test.yaml
vendored
Normal file
23
.github/workflows/test.yaml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
tags-ignore:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ">=1.25.1"
|
||||
- name: Test code
|
||||
run: make test
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,3 +1,9 @@
|
|||
edge-connect
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
|
||||
### direnv ###
|
||||
.direnv
|
||||
.envrc
|
||||
|
||||
edge-connect-client
|
||||
|
|
|
|||
|
|
@ -31,6 +31,18 @@ archives:
|
|||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
signs:
|
||||
- artifacts: checksum
|
||||
cmd: gpg
|
||||
args:
|
||||
- "--batch"
|
||||
- "-u"
|
||||
- "{{ .Env.GPG_FINGERPRINT }}"
|
||||
- "--output"
|
||||
- "${signature}"
|
||||
- "--detach-sign"
|
||||
- "${artifact}"
|
||||
|
||||
changelog:
|
||||
abbrev: 10
|
||||
filters:
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -28,7 +28,7 @@ clean:
|
|||
|
||||
# Lint the code
|
||||
lint:
|
||||
golangci-lint run
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2 run
|
||||
|
||||
# Run all checks (generate, test, lint)
|
||||
check: test lint
|
||||
|
|
|
|||
868
api/swagger_v2.json
Normal file
868
api/swagger_v2.json
Normal file
|
|
@ -0,0 +1,868 @@
|
|||
{
|
||||
"basePath": "/api/v1",
|
||||
"definitions": {
|
||||
"handler.App": {
|
||||
"properties": {
|
||||
"access_ports": {
|
||||
"description": "Application port to be exposed with ingress in format <protocol:port>.\nNecessary only when manifest is generated automatically. Otherwise, all\nthe ports has to be set up manually in YAML manifest.",
|
||||
"example": "tcp:80",
|
||||
"type": "string"
|
||||
},
|
||||
"allow_serverless": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"description": "Timestamp, format RFC3339.",
|
||||
"type": "string"
|
||||
},
|
||||
"defaultFlavor": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/handler.Flavor"
|
||||
}
|
||||
],
|
||||
"description": "Default resource config to be used."
|
||||
},
|
||||
"deployment": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/handler.DeploymentType"
|
||||
}
|
||||
],
|
||||
"example": "kubernetes"
|
||||
},
|
||||
"global_id": {
|
||||
"description": "Combination of key fields (local-<key.name><key.version><key.organisation>).",
|
||||
"type": "string"
|
||||
},
|
||||
"image_path": {
|
||||
"description": "Docker registry URL (e.g. docker.io/library/nginx:latest)",
|
||||
"type": "string"
|
||||
},
|
||||
"image_type": {
|
||||
"$ref": "#/definitions/handler.ImageType"
|
||||
},
|
||||
"key": {
|
||||
"$ref": "#/definitions/handler.AppKey"
|
||||
},
|
||||
"serverless_config": {
|
||||
"$ref": "#/definitions/handler.ServerlessConfig"
|
||||
},
|
||||
"updated_at": {
|
||||
"description": "Timestamp, format RFC3339.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.AppInst": {
|
||||
"properties": {
|
||||
"app_key": {
|
||||
"$ref": "#/definitions/handler.AppKey"
|
||||
},
|
||||
"cloudlet_loc": {
|
||||
"$ref": "#/definitions/handler.CloudletLoc"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"flavor": {
|
||||
"$ref": "#/definitions/handler.Flavor"
|
||||
},
|
||||
"ingress_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"$ref": "#/definitions/handler.AppInstKey"
|
||||
},
|
||||
"state": {
|
||||
"$ref": "#/definitions/handler.AppInstState"
|
||||
},
|
||||
"unique_id": {
|
||||
"description": "Combination of key fields (<key.organisation>-<key.name>-<cloudletKey.name>-<cloudletKey.organisation>).",
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.AppInstKey": {
|
||||
"properties": {
|
||||
"cloudlet_key": {
|
||||
"$ref": "#/definitions/handler.CloudletKey"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"organization": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.AppInstState": {
|
||||
"enum": [
|
||||
"Ready"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enum-varnames": [
|
||||
"AppInstStateReady"
|
||||
]
|
||||
},
|
||||
"handler.AppKey": {
|
||||
"description": "is a unique identifier.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"organization": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.CloudletKey": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"organization": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.CloudletLoc": {
|
||||
"properties": {
|
||||
"latitude": {
|
||||
"type": "number"
|
||||
},
|
||||
"longitude": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.DeploymentType": {
|
||||
"enum": [
|
||||
"kubernetes"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enum-varnames": [
|
||||
"DeploymentTypeKubernetes"
|
||||
]
|
||||
},
|
||||
"handler.Flavor": {
|
||||
"description": "is a default configuration is applied to app if no configuration\nis provided (e.g. in serverless config). Configuration can be checked at /auth/ctrl/CreateApp",
|
||||
"properties": {
|
||||
"name": {
|
||||
"$ref": "#/definitions/handler.FlavorName"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.FlavorName": {
|
||||
"enum": [
|
||||
"EU.small",
|
||||
"EU.medium",
|
||||
"EU.big",
|
||||
"EU.large"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enum-varnames": [
|
||||
"DefaultFlavorNameEUSmall",
|
||||
"DefaultFlavorNameEUMedium",
|
||||
"DefaultFlavorNameEUBig",
|
||||
"DefaultFlavorNameEULarge"
|
||||
]
|
||||
},
|
||||
"handler.ImageType": {
|
||||
"enum": [
|
||||
"docker"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enum-varnames": [
|
||||
"ImageTypeDocker"
|
||||
]
|
||||
},
|
||||
"handler.Region": {
|
||||
"enum": [
|
||||
"EU"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enum-varnames": [
|
||||
"RegionEU"
|
||||
]
|
||||
},
|
||||
"handler.RequestCreateApp": {
|
||||
"description": "necessary App details to create an entity.",
|
||||
"properties": {
|
||||
"app": {
|
||||
"properties": {
|
||||
"access_ports": {
|
||||
"description": "Application port to be exposed with ingress in format <protocol:port>.\nNecessary only when manifest is generated automatically. Otherwise,\nall the ports has to be set up manually in YAML manifest.",
|
||||
"example": "tcp:80",
|
||||
"type": "string"
|
||||
},
|
||||
"allow_serverless": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultFlavor": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/handler.Flavor"
|
||||
}
|
||||
],
|
||||
"description": "Default resource config to be used."
|
||||
},
|
||||
"deployment": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/handler.DeploymentType"
|
||||
}
|
||||
],
|
||||
"example": "kubernetes"
|
||||
},
|
||||
"deployment_generator": {
|
||||
"description": "Technical field. Required for providing custom manifest",
|
||||
"type": "string"
|
||||
},
|
||||
"deployment_manifest": {
|
||||
"description": "Kubernetes manifest. ACCEPTS ONLY DEPLOYMENTS AND SERVICES.",
|
||||
"type": "string"
|
||||
},
|
||||
"image_path": {
|
||||
"description": "Docker registry URL (e.g. docker.io/library/nginx:latest)",
|
||||
"type": "string"
|
||||
},
|
||||
"image_type": {
|
||||
"$ref": "#/definitions/handler.ImageType"
|
||||
},
|
||||
"key": {
|
||||
"$ref": "#/definitions/handler.AppKey"
|
||||
},
|
||||
"serverless_config": {
|
||||
"$ref": "#/definitions/handler.ServerlessConfig"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"region": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/handler.Region"
|
||||
}
|
||||
],
|
||||
"description": "Region to create instance at.",
|
||||
"example": "EU"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"example": {
|
||||
"appWithoutManifest": {
|
||||
"region": "EU",
|
||||
"app": {
|
||||
"key": {
|
||||
"organization": "DeveloperOrg",
|
||||
"name": "test-app-without-manifest",
|
||||
"version": "1.0"
|
||||
},
|
||||
"access_ports": "tcp:80",
|
||||
"serverless_config": {},
|
||||
"deployment": "kubernetes",
|
||||
"image_type": "Docker",
|
||||
"image_path": "docker.io/library/nginx:latest",
|
||||
"allow_serverless": true,
|
||||
"defaultFlavor": {
|
||||
"name": "EU.small"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appWitManifest": {
|
||||
"region": "EU",
|
||||
"app": {
|
||||
"key": {
|
||||
"organization": "DeveloperOrg",
|
||||
"name": "test-app-without-manifest",
|
||||
"version": "1.0"
|
||||
},
|
||||
"serverless_config": {},
|
||||
"deployment": "kubernetes",
|
||||
"image_type": "Docker",
|
||||
"image_path": "docker.io/library/nginx:latest",
|
||||
"allow_serverless": true,
|
||||
"defaultFlavor": {
|
||||
"name": "EU.small"
|
||||
},
|
||||
"deployment_manifest": "apiVersion: v1\nkind: Service\nmetadata:\n name: example-app-tcp\n labels:\n run: example-app\nspec:\n type: LoadBalancer\n ports:\n - name: tcp8080\n protocol: TCP\n port: 80\n targetPort: 80\n selector:\n run: example-app\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: example-app-deployment\nspec:\n replicas: 1\n selector:\n matchLabels:\n run: example-app\n template:\n metadata:\n labels:\n run: example-app\n mexDeployGen: kubernetes-basic\n spec:\n volumes:\n imagePullSecrets:\n - name: mtr.devops.telekom.de\n containers:\n - name: example-app\n image: docker.io/library/nginx:latest\n imagePullPolicy: Always\n ports:\n - containerPort: 80\n protocol: TCP\n",
|
||||
"deployment_generator": "kubernetes-basic"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"handler.RequestCreateAppInst": {
|
||||
"properties": {
|
||||
"appinst": {
|
||||
"properties": {
|
||||
"app_key": {
|
||||
"$ref": "#/definitions/handler.AppKey"
|
||||
},
|
||||
"key": {
|
||||
"$ref": "#/definitions/handler.AppInstKey"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"region": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/handler.Region"
|
||||
}
|
||||
],
|
||||
"description": "Region to create instance at.",
|
||||
"example": "EU"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.RequestCreateAppInstMessage": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.RequestDeleteApp": {
|
||||
"properties": {
|
||||
"key": {
|
||||
"$ref": "#/definitions/handler.AppKey"
|
||||
},
|
||||
"region": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/handler.Region"
|
||||
}
|
||||
],
|
||||
"example": "EU"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.RequestDeleteAppInst": {
|
||||
"properties": {
|
||||
"key": {
|
||||
"$ref": "#/definitions/handler.AppInstKey"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.RequestShowApp": {
|
||||
"properties": {
|
||||
"access_ports": {
|
||||
"description": "Application port to be exposed with ingress. Necessary only when\nmanifest is generated automatically. Otherwise, all the ports has to be\nset up manually in YAML manifest",
|
||||
"example": "tcp:80",
|
||||
"type": "string"
|
||||
},
|
||||
"defaultFlavor": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/handler.Flavor"
|
||||
}
|
||||
],
|
||||
"description": "Default resource config to be used."
|
||||
},
|
||||
"deployment": {
|
||||
"$ref": "#/definitions/handler.DeploymentType"
|
||||
},
|
||||
"image_path": {
|
||||
"description": "Docker registry URL (e.g. docker.io/library/nginx:latest)",
|
||||
"type": "string"
|
||||
},
|
||||
"image_type": {
|
||||
"$ref": "#/definitions/handler.ImageType"
|
||||
},
|
||||
"key": {
|
||||
"$ref": "#/definitions/handler.AppKey"
|
||||
},
|
||||
"region": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/handler.Region"
|
||||
}
|
||||
],
|
||||
"example": "EU"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.RequestShowAppInst": {
|
||||
"properties": {
|
||||
"app_key": {
|
||||
"$ref": "#/definitions/handler.AppKey"
|
||||
},
|
||||
"key": {
|
||||
"$ref": "#/definitions/handler.AppInstKey"
|
||||
},
|
||||
"region": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/handler.Region"
|
||||
}
|
||||
],
|
||||
"description": "Region to create instance at.",
|
||||
"example": "EU"
|
||||
},
|
||||
"state": {
|
||||
"$ref": "#/definitions/handler.AppInstState"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.RequestUpdateApp": {
|
||||
"properties": {
|
||||
"access_ports": {
|
||||
"description": "Can be updated only if manifest is generated by EdgeXR.",
|
||||
"example": "tcp:80",
|
||||
"type": "string"
|
||||
},
|
||||
"defaultFlavor": {
|
||||
"$ref": "#/definitions/handler.Flavor"
|
||||
},
|
||||
"image_path": {
|
||||
"description": "Docker registry URL (e.g. docker.io/library/nginx:latest)",
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/handler.AppKey"
|
||||
}
|
||||
],
|
||||
"description": "Immutable."
|
||||
},
|
||||
"region": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/handler.Region"
|
||||
}
|
||||
],
|
||||
"description": "Immutable.",
|
||||
"example": "EU"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.RequestUpdateAppInst": {
|
||||
"properties": {
|
||||
"flavor": {
|
||||
"$ref": "#/definitions/handler.Flavor"
|
||||
},
|
||||
"key": {
|
||||
"$ref": "#/definitions/handler.AppInstKey"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"handler.ServerlessConfig": {
|
||||
"description": "is a default configuration is applied to app if no configuration\nis provided (e.g. in serverless config). Configuration can be checked at /auth/ctrl/CreateApp",
|
||||
"properties": {
|
||||
"min_replicas": {
|
||||
"description": "number of replicas (at least 1).",
|
||||
"type": "integer"
|
||||
},
|
||||
"ram": {
|
||||
"description": "RAM in MB.",
|
||||
"type": "integer"
|
||||
},
|
||||
"vcpus": {
|
||||
"description": "Virtual CPUs.",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"externalDocs": {
|
||||
"description": "OpenAPI",
|
||||
"url": "https://swagger.io/resources/open-api/"
|
||||
},
|
||||
"info": {
|
||||
"contact": {},
|
||||
"description": "# Introduction\n The Master Controller (MC) serves as the central\ngateway for orchestrating edge applications and provides several services to both\napplication developers and operators. For application developers, these APIs allow\nthe management and monitoring of deployments for edge applications. For infrastructure\noperators, these APIs provide ways to manage and monitor the usage of cloudlet\ninfrastructures. Both developers and operators can take advantage of these APIS\nto manage users within the Organization. ## Important note.\n API can return more\nfields than provided in the specification. Specification is a main source of truth.",
|
||||
"license": {
|
||||
"name": "Apache 2.0",
|
||||
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
|
||||
},
|
||||
"title": "Edge Connect API",
|
||||
"version": "2.0"
|
||||
},
|
||||
"paths": {
|
||||
"/": {
|
||||
"get": {
|
||||
"description": "Returns OK when server is set-up and running.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK\" \"ok",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "Show server is running",
|
||||
"tags": [
|
||||
"monitoring"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/ctrl/CreateApp": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Creates App specification, validating of the params. Please, read\ndescription of the fields since not every one is required in every configuration.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "body",
|
||||
"in": "body",
|
||||
"name": "_",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handler.RequestCreateApp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"summary": "Creates App specification.",
|
||||
"tags": [
|
||||
"App"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/ctrl/CreateAppInst": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Create App instance on the cloudlet. Requests can be done as web\nsocket or regular http request returning all the steps as array of json messages.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "body",
|
||||
"in": "body",
|
||||
"name": "_",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handler.RequestCreateAppInst"
|
||||
}
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/handler.RequestCreateAppInstMessage"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"summary": "Create app instance.",
|
||||
"tags": [
|
||||
"AppInst"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/ctrl/DeleteApp": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Update app specification with limitation to the key.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "body",
|
||||
"in": "body",
|
||||
"name": "_",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handler.RequestDeleteApp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"summary": "Update app specs.",
|
||||
"tags": [
|
||||
"App"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/ctrl/DeleteAppInst": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Deletes app instance at the specified cloudlet.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "body",
|
||||
"in": "body",
|
||||
"name": "_",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handler.RequestDeleteAppInst"
|
||||
}
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"summary": "Deletes app instance by key.",
|
||||
"tags": [
|
||||
"AppInst"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/ctrl/ShowApp": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "returns app specifications for provided region. Filter is done\nwith providing app fields from the model.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "body",
|
||||
"in": "body",
|
||||
"name": "_",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handler.RequestShowApp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/handler.App"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"summary": "Shows list of apps for the region.",
|
||||
"tags": [
|
||||
"App"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/ctrl/ShowAppInst": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Returns app instances for provided region. Filter is done with\nproviding app instances fields from the model.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "body",
|
||||
"in": "body",
|
||||
"name": "_",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handler.RequestShowAppInst"
|
||||
}
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/handler.AppInst"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"summary": "Shows list of app instances for the region.",
|
||||
"tags": [
|
||||
"AppInst"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/ctrl/UpdateApp": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Update app specification with limitation to the key.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "body",
|
||||
"in": "body",
|
||||
"name": "_",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handler.RequestUpdateApp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"summary": "Update app specs.",
|
||||
"tags": [
|
||||
"App"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/ctrl/UpdateAppInst": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Update app instance by key with limited set of fields.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "body",
|
||||
"in": "body",
|
||||
"name": "_",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handler.RequestUpdateAppInst"
|
||||
}
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"summary": "Update app instance by key.",
|
||||
"tags": [
|
||||
"AppInst"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"Bearer": {
|
||||
"type": "basic"
|
||||
}
|
||||
},
|
||||
"swagger": "2.0"
|
||||
}
|
||||
226
cmd/app.go
226
cmd/app.go
|
|
@ -3,12 +3,15 @@ package cmd
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
|
@ -34,7 +37,7 @@ func validateBaseURL(baseURL string) error {
|
|||
return fmt.Errorf("user and or password should not be set")
|
||||
}
|
||||
|
||||
if !(url.Path == "" || url.Path == "/") {
|
||||
if url.Path != "" && url.Path != "/" {
|
||||
return fmt.Errorf("should not contain any path '%s'", url.Path)
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +52,15 @@ func validateBaseURL(baseURL string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func newSDKClient() *edgeconnect.Client {
|
||||
func getAPIVersion() string {
|
||||
version := viper.GetString("api_version")
|
||||
if version == "" {
|
||||
version = "v2" // default to v2
|
||||
}
|
||||
return strings.ToLower(version)
|
||||
}
|
||||
|
||||
func newSDKClientV1() *edgeconnect.Client {
|
||||
baseURL := viper.GetString("base_url")
|
||||
username := viper.GetString("username")
|
||||
password := viper.GetString("password")
|
||||
|
|
@ -60,16 +71,53 @@ func newSDKClient() *edgeconnect.Client {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 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,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
)
|
||||
return edgeconnect.NewClientWithCredentials(baseURL, username, password, opts...)
|
||||
}
|
||||
|
||||
// Fallback to no auth for now - in production should require auth
|
||||
return edgeconnect.NewClient(baseURL,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
)
|
||||
return edgeconnect.NewClient(baseURL, opts...)
|
||||
}
|
||||
|
||||
func newSDKClientV2() *v2.Client {
|
||||
baseURL := viper.GetString("base_url")
|
||||
username := viper.GetString("username")
|
||||
password := viper.GetString("password")
|
||||
|
||||
err := validateBaseURL(baseURL)
|
||||
if err != nil {
|
||||
fmt.Printf("Error parsing baseURL: '%s' with error: %s\n", baseURL, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Build options
|
||||
opts := []v2.Option{
|
||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
}
|
||||
|
||||
// Add logger only if debug flag is set
|
||||
if debug {
|
||||
logger := log.New(os.Stderr, "[DEBUG] ", log.LstdFlags)
|
||||
opts = append(opts, v2.WithLogger(logger))
|
||||
}
|
||||
|
||||
if username != "" && password != "" {
|
||||
return v2.NewClientWithCredentials(baseURL, username, password, opts...)
|
||||
}
|
||||
|
||||
// Fallback to no auth for now - in production should require auth
|
||||
return v2.NewClient(baseURL, opts...)
|
||||
}
|
||||
|
||||
var appCmd = &cobra.Command{
|
||||
|
|
@ -82,19 +130,37 @@ var createAppCmd = &cobra.Command{
|
|||
Use: "create",
|
||||
Short: "Create a new Edge Connect application",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
input := &edgeconnect.NewAppInput{
|
||||
Region: region,
|
||||
App: edgeconnect.App{
|
||||
Key: edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
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)
|
||||
}
|
||||
|
||||
err := c.CreateApp(context.Background(), input)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating app: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -107,19 +173,35 @@ var showAppCmd = &cobra.Command{
|
|||
Use: "show",
|
||||
Short: "Show details of an Edge Connect application",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
apiVersion := getAPIVersion()
|
||||
|
||||
app, err := c.ShowApp(context.Background(), appKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error showing app: %v\n", err)
|
||||
os.Exit(1)
|
||||
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)
|
||||
}
|
||||
fmt.Printf("Application details:\n%+v\n", app)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -127,21 +209,40 @@ var listAppsCmd = &cobra.Command{
|
|||
Use: "list",
|
||||
Short: "List Edge Connect applications",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
apiVersion := getAPIVersion()
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -150,14 +251,27 @@ var deleteAppCmd = &cobra.Command{
|
|||
Use: "delete",
|
||||
Short: "Delete an Edge Connect application",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
apiVersion := getAPIVersion()
|
||||
var err error
|
||||
|
||||
if apiVersion == "v1" {
|
||||
c := newSDKClientV1()
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
err = c.DeleteApp(context.Background(), appKey, region)
|
||||
} else {
|
||||
c := newSDKClientV2()
|
||||
appKey := v2.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
err = c.DeleteApp(context.Background(), appKey, region)
|
||||
}
|
||||
|
||||
err := c.DeleteApp(context.Background(), appKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error deleting app: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -177,12 +291,18 @@ func init() {
|
|||
cmd.Flags().StringVarP(&appName, "name", "n", "", "application name")
|
||||
cmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
|
||||
cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)")
|
||||
cmd.MarkFlagRequired("org")
|
||||
cmd.MarkFlagRequired("region")
|
||||
if err := cmd.MarkFlagRequired("org"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cmd.MarkFlagRequired("region"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add required name flag for specific commands
|
||||
for _, cmd := range []*cobra.Command{createAppCmd, showAppCmd, deleteAppCmd} {
|
||||
cmd.MarkFlagRequired("name")
|
||||
if err := cmd.MarkFlagRequired("name"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
166
cmd/apply.go
166
cmd/apply.go
|
|
@ -10,14 +10,16 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/apply"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
applyv1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/apply/v1"
|
||||
applyv2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/apply/v2"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
configFile string
|
||||
dryRun bool
|
||||
configFile string
|
||||
dryRun bool
|
||||
autoApprove bool
|
||||
)
|
||||
|
||||
var applyCmd = &cobra.Command{
|
||||
|
|
@ -29,18 +31,18 @@ the necessary changes to deploy your applications across multiple cloudlets.`,
|
|||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if configFile == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n")
|
||||
cmd.Usage()
|
||||
_ = cmd.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := runApply(configFile, dryRun); err != nil {
|
||||
if err := runApply(configFile, dryRun, autoApprove); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func runApply(configPath string, isDryRun bool) error {
|
||||
func runApply(configPath string, isDryRun bool, autoApprove bool) error {
|
||||
// Step 1: Validate and resolve config file path
|
||||
absPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
|
|
@ -66,16 +68,27 @@ func runApply(configPath string, isDryRun bool) error {
|
|||
|
||||
fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name)
|
||||
|
||||
// Step 3: Create EdgeConnect client
|
||||
client := newSDKClient()
|
||||
// Step 3: Determine API version and create appropriate client
|
||||
apiVersion := getAPIVersion()
|
||||
|
||||
// Step 4: Create deployment planner
|
||||
planner := apply.NewPlanner(client)
|
||||
// Step 4-6: Execute deployment based on API version
|
||||
if apiVersion == "v1" {
|
||||
return runApplyV1(cfg, manifestContent, isDryRun, autoApprove)
|
||||
}
|
||||
return runApplyV2(cfg, manifestContent, isDryRun, autoApprove)
|
||||
}
|
||||
|
||||
// Step 5: Generate deployment plan
|
||||
func runApplyV1(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error {
|
||||
// Create v1 client
|
||||
client := newSDKClientV1()
|
||||
|
||||
// Create deployment planner
|
||||
planner := applyv1.NewPlanner(client)
|
||||
|
||||
// Generate deployment plan
|
||||
fmt.Println("🔍 Analyzing current state and generating deployment plan...")
|
||||
|
||||
planOptions := apply.DefaultPlanOptions()
|
||||
planOptions := applyv1.DefaultPlanOptions()
|
||||
planOptions.DryRun = isDryRun
|
||||
|
||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
||||
|
|
@ -83,7 +96,7 @@ func runApply(configPath string, isDryRun bool) error {
|
|||
return fmt.Errorf("failed to generate deployment plan: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: Display plan summary
|
||||
// Display plan summary
|
||||
fmt.Println("\n📋 Deployment Plan:")
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
fmt.Println(result.Plan.Summary)
|
||||
|
|
@ -97,13 +110,13 @@ func runApply(configPath string, isDryRun bool) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Step 7: If dry-run, stop here
|
||||
// If dry-run, stop here
|
||||
if isDryRun {
|
||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 8: Confirm deployment (in non-dry-run mode)
|
||||
// Confirm deployment
|
||||
if result.Plan.TotalActions == 0 {
|
||||
fmt.Println("\n✅ No changes needed. Resources are already in desired state.")
|
||||
return nil
|
||||
|
|
@ -112,21 +125,103 @@ func runApply(configPath string, isDryRun bool) error {
|
|||
fmt.Printf("\nThis will perform %d actions. Estimated time: %v\n",
|
||||
result.Plan.TotalActions, result.Plan.EstimatedDuration)
|
||||
|
||||
if !confirmDeployment() {
|
||||
if !autoApprove && !confirmDeployment() {
|
||||
fmt.Println("Deployment cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 9: Execute deployment
|
||||
// Execute deployment
|
||||
fmt.Println("\n🚀 Starting deployment...")
|
||||
|
||||
manager := apply.NewResourceManager(client, apply.WithLogger(log.Default()))
|
||||
manager := applyv1.NewResourceManager(client, applyv1.WithLogger(log.Default()))
|
||||
deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deployment failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 10: Display results
|
||||
// Display results
|
||||
return displayDeploymentResults(deployResult)
|
||||
}
|
||||
|
||||
func runApplyV2(cfg *config.EdgeConnectConfig, manifestContent string, isDryRun bool, autoApprove bool) error {
|
||||
// Create v2 client
|
||||
client := newSDKClientV2()
|
||||
|
||||
// Create deployment planner
|
||||
planner := applyv2.NewPlanner(client)
|
||||
|
||||
// Generate deployment plan
|
||||
fmt.Println("🔍 Analyzing current state and generating deployment plan...")
|
||||
|
||||
planOptions := applyv2.DefaultPlanOptions()
|
||||
planOptions.DryRun = isDryRun
|
||||
|
||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate deployment plan: %w", err)
|
||||
}
|
||||
|
||||
// Display plan summary
|
||||
fmt.Println("\n📋 Deployment Plan:")
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
fmt.Println(result.Plan.Summary)
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
|
||||
// Display warnings if any
|
||||
if len(result.Warnings) > 0 {
|
||||
fmt.Println("\n⚠️ Warnings:")
|
||||
for _, warning := range result.Warnings {
|
||||
fmt.Printf(" • %s\n", warning)
|
||||
}
|
||||
}
|
||||
|
||||
// If dry-run, stop here
|
||||
if isDryRun {
|
||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm deployment
|
||||
if result.Plan.TotalActions == 0 {
|
||||
fmt.Println("\n✅ No changes needed. Resources are already in desired state.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\nThis will perform %d actions. Estimated time: %v\n",
|
||||
result.Plan.TotalActions, result.Plan.EstimatedDuration)
|
||||
|
||||
if !autoApprove && !confirmDeployment() {
|
||||
fmt.Println("Deployment cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute deployment
|
||||
fmt.Println("\n🚀 Starting deployment...")
|
||||
|
||||
manager := applyv2.NewResourceManager(client, applyv2.WithLogger(log.Default()))
|
||||
deployResult, err := manager.ApplyDeployment(context.Background(), result.Plan, cfg, manifestContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deployment failed: %w", err)
|
||||
}
|
||||
|
||||
// Display results
|
||||
return displayDeploymentResults(deployResult)
|
||||
}
|
||||
|
||||
func displayDeploymentResults(result interface{}) error {
|
||||
// Use reflection or type assertion to handle both v1 and v2 result types
|
||||
// For now, we'll use a simple approach that works with both
|
||||
switch r := result.(type) {
|
||||
case *applyv1.ExecutionResult:
|
||||
return displayDeploymentResultsV1(r)
|
||||
case *applyv2.ExecutionResult:
|
||||
return displayDeploymentResultsV2(r)
|
||||
default:
|
||||
return fmt.Errorf("unknown deployment result type")
|
||||
}
|
||||
}
|
||||
|
||||
func displayDeploymentResultsV1(deployResult *applyv1.ExecutionResult) error {
|
||||
if deployResult.Success {
|
||||
fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration)
|
||||
if len(deployResult.CompletedActions) > 0 {
|
||||
|
|
@ -148,14 +243,38 @@ func runApply(configPath string, isDryRun bool) error {
|
|||
}
|
||||
return fmt.Errorf("deployment failed with %d failed actions", len(deployResult.FailedActions))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func displayDeploymentResultsV2(deployResult *applyv2.ExecutionResult) error {
|
||||
if deployResult.Success {
|
||||
fmt.Printf("\n✅ Deployment completed successfully in %v\n", deployResult.Duration)
|
||||
if len(deployResult.CompletedActions) > 0 {
|
||||
fmt.Println("\nCompleted actions:")
|
||||
for _, action := range deployResult.CompletedActions {
|
||||
fmt.Printf(" ✅ %s %s\n", action.Type, action.Target)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("\n❌ Deployment failed after %v\n", deployResult.Duration)
|
||||
if deployResult.Error != nil {
|
||||
fmt.Printf("Error: %v\n", deployResult.Error)
|
||||
}
|
||||
if len(deployResult.FailedActions) > 0 {
|
||||
fmt.Println("\nFailed actions:")
|
||||
for _, action := range deployResult.FailedActions {
|
||||
fmt.Printf(" ❌ %s %s: %v\n", action.Type, action.Target, action.Error)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("deployment failed with %d failed actions", len(deployResult.FailedActions))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func confirmDeployment() bool {
|
||||
fmt.Print("Do you want to proceed? (yes/no): ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
_, _ = fmt.Scanln(&response)
|
||||
|
||||
switch response {
|
||||
case "yes", "y", "YES", "Y":
|
||||
|
|
@ -170,6 +289,9 @@ func init() {
|
|||
|
||||
applyCmd.Flags().StringVarP(&configFile, "file", "f", "", "configuration file path (required)")
|
||||
applyCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without applying them")
|
||||
applyCmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "automatically approve the deployment plan")
|
||||
|
||||
applyCmd.MarkFlagRequired("file")
|
||||
if err := applyCmd.MarkFlagRequired("file"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
296
cmd/delete.go
Normal file
296
cmd/delete.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
// ABOUTME: CLI command for deleting EdgeConnect applications from YAML configuration
|
||||
// ABOUTME: Removes applications and their instances based on configuration file specification
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
deletev1 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/delete/v1"
|
||||
deletev2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/delete/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
deleteConfigFile string
|
||||
deleteDryRun bool
|
||||
deleteAutoApprove bool
|
||||
)
|
||||
|
||||
var deleteCmd = &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "Delete EdgeConnect applications from configuration files",
|
||||
Long: `Delete EdgeConnect applications and their instances based on YAML configuration files.
|
||||
This command reads a configuration file, finds matching resources, and deletes them.
|
||||
Instances are always deleted before the application.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if deleteConfigFile == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: configuration file is required\n")
|
||||
_ = cmd.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := runDelete(deleteConfigFile, deleteDryRun, deleteAutoApprove); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func runDelete(configPath string, isDryRun bool, autoApprove bool) error {
|
||||
// Step 1: Validate and resolve config file path
|
||||
absPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve config file path: %w", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("configuration file not found: %s", absPath)
|
||||
}
|
||||
|
||||
fmt.Printf("📄 Loading configuration from: %s\n", absPath)
|
||||
|
||||
// Step 2: Parse and validate configuration
|
||||
parser := config.NewParser()
|
||||
cfg, _, err := parser.ParseFile(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := parser.Validate(cfg); err != nil {
|
||||
return fmt.Errorf("configuration validation failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Configuration loaded successfully: %s\n", cfg.Metadata.Name)
|
||||
|
||||
// Step 3: Determine API version and create appropriate client
|
||||
apiVersion := getAPIVersion()
|
||||
|
||||
// Step 4: Execute deletion based on API version
|
||||
if apiVersion == "v1" {
|
||||
return runDeleteV1(cfg, isDryRun, autoApprove)
|
||||
}
|
||||
return runDeleteV2(cfg, isDryRun, autoApprove)
|
||||
}
|
||||
|
||||
func runDeleteV1(cfg *config.EdgeConnectConfig, isDryRun bool, autoApprove bool) error {
|
||||
// Create v1 client
|
||||
client := newSDKClientV1()
|
||||
|
||||
// Create deletion planner
|
||||
planner := deletev1.NewPlanner(client)
|
||||
|
||||
// Generate deletion plan
|
||||
fmt.Println("🔍 Analyzing current state and generating deletion plan...")
|
||||
|
||||
planOptions := deletev1.DefaultPlanOptions()
|
||||
planOptions.DryRun = isDryRun
|
||||
|
||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate deletion plan: %w", err)
|
||||
}
|
||||
|
||||
// Display plan summary
|
||||
fmt.Println("\n📋 Deletion Plan:")
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
fmt.Println(result.Plan.Summary)
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
|
||||
// Display warnings if any
|
||||
if len(result.Warnings) > 0 {
|
||||
fmt.Println("\n⚠️ Warnings:")
|
||||
for _, warning := range result.Warnings {
|
||||
fmt.Printf(" • %s\n", warning)
|
||||
}
|
||||
}
|
||||
|
||||
// If dry-run, stop here
|
||||
if isDryRun {
|
||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if there's anything to delete
|
||||
if result.Plan.TotalActions == 0 {
|
||||
fmt.Println("\n✅ No resources found to delete.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\nThis will delete %d resource(s). Estimated time: %v\n",
|
||||
result.Plan.TotalActions, result.Plan.EstimatedDuration)
|
||||
|
||||
if !autoApprove && !confirmDeletion() {
|
||||
fmt.Println("Deletion cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute deletion
|
||||
fmt.Println("\n🗑️ Starting deletion...")
|
||||
|
||||
manager := deletev1.NewResourceManager(client, deletev1.WithLogger(log.Default()))
|
||||
deleteResult, err := manager.ExecuteDeletion(context.Background(), result.Plan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deletion failed: %w", err)
|
||||
}
|
||||
|
||||
// Display results
|
||||
return displayDeletionResults(deleteResult)
|
||||
}
|
||||
|
||||
func runDeleteV2(cfg *config.EdgeConnectConfig, isDryRun bool, autoApprove bool) error {
|
||||
// Create v2 client
|
||||
client := newSDKClientV2()
|
||||
|
||||
// Create deletion planner
|
||||
planner := deletev2.NewPlanner(client)
|
||||
|
||||
// Generate deletion plan
|
||||
fmt.Println("🔍 Analyzing current state and generating deletion plan...")
|
||||
|
||||
planOptions := deletev2.DefaultPlanOptions()
|
||||
planOptions.DryRun = isDryRun
|
||||
|
||||
result, err := planner.PlanWithOptions(context.Background(), cfg, planOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate deletion plan: %w", err)
|
||||
}
|
||||
|
||||
// Display plan summary
|
||||
fmt.Println("\n📋 Deletion Plan:")
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
fmt.Println(result.Plan.Summary)
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
|
||||
// Display warnings if any
|
||||
if len(result.Warnings) > 0 {
|
||||
fmt.Println("\n⚠️ Warnings:")
|
||||
for _, warning := range result.Warnings {
|
||||
fmt.Printf(" • %s\n", warning)
|
||||
}
|
||||
}
|
||||
|
||||
// If dry-run, stop here
|
||||
if isDryRun {
|
||||
fmt.Println("\n🔍 Dry-run complete. No changes were made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if there's anything to delete
|
||||
if result.Plan.TotalActions == 0 {
|
||||
fmt.Println("\n✅ No resources found to delete.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\nThis will delete %d resource(s). Estimated time: %v\n",
|
||||
result.Plan.TotalActions, result.Plan.EstimatedDuration)
|
||||
|
||||
if !autoApprove && !confirmDeletion() {
|
||||
fmt.Println("Deletion cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute deletion
|
||||
fmt.Println("\n🗑️ Starting deletion...")
|
||||
|
||||
manager := deletev2.NewResourceManager(client, deletev2.WithLogger(log.Default()))
|
||||
deleteResult, err := manager.ExecuteDeletion(context.Background(), result.Plan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deletion failed: %w", err)
|
||||
}
|
||||
|
||||
// Display results
|
||||
return displayDeletionResults(deleteResult)
|
||||
}
|
||||
|
||||
func displayDeletionResults(result interface{}) error {
|
||||
// Use type assertion to handle both v1 and v2 result types
|
||||
switch r := result.(type) {
|
||||
case *deletev1.DeletionResult:
|
||||
return displayDeletionResultsV1(r)
|
||||
case *deletev2.DeletionResult:
|
||||
return displayDeletionResultsV2(r)
|
||||
default:
|
||||
return fmt.Errorf("unknown deletion result type")
|
||||
}
|
||||
}
|
||||
|
||||
func displayDeletionResultsV1(deleteResult *deletev1.DeletionResult) error {
|
||||
if deleteResult.Success {
|
||||
fmt.Printf("\n✅ Deletion completed successfully in %v\n", deleteResult.Duration)
|
||||
if len(deleteResult.CompletedActions) > 0 {
|
||||
fmt.Println("\nDeleted resources:")
|
||||
for _, action := range deleteResult.CompletedActions {
|
||||
fmt.Printf(" ✅ %s %s\n", action.Type, action.Target)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("\n❌ Deletion failed after %v\n", deleteResult.Duration)
|
||||
if deleteResult.Error != nil {
|
||||
fmt.Printf("Error: %v\n", deleteResult.Error)
|
||||
}
|
||||
if len(deleteResult.FailedActions) > 0 {
|
||||
fmt.Println("\nFailed actions:")
|
||||
for _, action := range deleteResult.FailedActions {
|
||||
fmt.Printf(" ❌ %s %s: %v\n", action.Type, action.Target, action.Error)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("deletion failed with %d failed actions", len(deleteResult.FailedActions))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func displayDeletionResultsV2(deleteResult *deletev2.DeletionResult) error {
|
||||
if deleteResult.Success {
|
||||
fmt.Printf("\n✅ Deletion completed successfully in %v\n", deleteResult.Duration)
|
||||
if len(deleteResult.CompletedActions) > 0 {
|
||||
fmt.Println("\nDeleted resources:")
|
||||
for _, action := range deleteResult.CompletedActions {
|
||||
fmt.Printf(" ✅ %s %s\n", action.Type, action.Target)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("\n❌ Deletion failed after %v\n", deleteResult.Duration)
|
||||
if deleteResult.Error != nil {
|
||||
fmt.Printf("Error: %v\n", deleteResult.Error)
|
||||
}
|
||||
if len(deleteResult.FailedActions) > 0 {
|
||||
fmt.Println("\nFailed actions:")
|
||||
for _, action := range deleteResult.FailedActions {
|
||||
fmt.Printf(" ❌ %s %s: %v\n", action.Type, action.Target, action.Error)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("deletion failed with %d failed actions", len(deleteResult.FailedActions))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func confirmDeletion() bool {
|
||||
fmt.Print("Do you want to proceed with deletion? (yes/no): ")
|
||||
var response string
|
||||
_, _ = fmt.Scanln(&response)
|
||||
|
||||
switch response {
|
||||
case "yes", "y", "YES", "Y":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(deleteCmd)
|
||||
|
||||
deleteCmd.Flags().StringVarP(&deleteConfigFile, "file", "f", "", "configuration file path (required)")
|
||||
deleteCmd.Flags().BoolVar(&deleteDryRun, "dry-run", false, "preview deletion without actually deleting resources")
|
||||
deleteCmd.Flags().BoolVar(&deleteAutoApprove, "auto-approve", false, "automatically approve the deletion plan")
|
||||
|
||||
if err := deleteCmd.MarkFlagRequired("file"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
241
cmd/instance.go
241
cmd/instance.go
|
|
@ -5,7 +5,8 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ var (
|
|||
cloudletOrg string
|
||||
instanceName string
|
||||
flavorName string
|
||||
appId string
|
||||
)
|
||||
|
||||
var appInstanceCmd = &cobra.Command{
|
||||
|
|
@ -26,30 +28,59 @@ var createInstanceCmd = &cobra.Command{
|
|||
Use: "create",
|
||||
Short: "Create a new Edge Connect application instance",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
input := &edgeconnect.NewAppInstanceInput{
|
||||
Region: region,
|
||||
AppInst: edgeconnect.AppInstance{
|
||||
Key: edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
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,
|
||||
},
|
||||
},
|
||||
AppKey: edgeconnect.AppKey{
|
||||
Organization: organization,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
}
|
||||
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,
|
||||
},
|
||||
},
|
||||
Flavor: edgeconnect.Flavor{
|
||||
Name: flavorName,
|
||||
},
|
||||
},
|
||||
}
|
||||
err = c.CreateAppInstance(context.Background(), input)
|
||||
}
|
||||
|
||||
err := c.CreateAppInstance(context.Background(), input)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating app instance: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -62,22 +93,43 @@ var showInstanceCmd = &cobra.Command{
|
|||
Use: "show",
|
||||
Short: "Show details of an Edge Connect application instance",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
apiVersion := getAPIVersion()
|
||||
|
||||
instance, err := c.ShowAppInstance(context.Background(), instanceKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error showing app instance: %v\n", err)
|
||||
os.Exit(1)
|
||||
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)
|
||||
}
|
||||
fmt.Printf("Application instance details:\n%+v\n", instance)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -85,24 +137,48 @@ var listInstancesCmd = &cobra.Command{
|
|||
Use: "list",
|
||||
Short: "List Edge Connect application instances",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
}
|
||||
apiVersion := getAPIVersion()
|
||||
|
||||
instances, err := c.ShowAppInstances(context.Background(), instanceKey, 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -111,17 +187,33 @@ var deleteInstanceCmd = &cobra.Command{
|
|||
Use: "delete",
|
||||
Short: "Delete an Edge Connect application instance",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newSDKClient()
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
},
|
||||
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)
|
||||
}
|
||||
|
||||
err := c.DeleteAppInstance(context.Background(), instanceKey, region)
|
||||
if err != nil {
|
||||
fmt.Printf("Error deleting app instance: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -142,18 +234,33 @@ func init() {
|
|||
cmd.Flags().StringVarP(&cloudletName, "cloudlet", "c", "", "cloudlet name (required)")
|
||||
cmd.Flags().StringVarP(&cloudletOrg, "cloudlet-org", "", "", "cloudlet organization (required)")
|
||||
cmd.Flags().StringVarP(®ion, "region", "r", "", "region (required)")
|
||||
cmd.Flags().StringVarP(&appId, "app-id", "i", "", "application id")
|
||||
|
||||
cmd.MarkFlagRequired("org")
|
||||
cmd.MarkFlagRequired("name")
|
||||
cmd.MarkFlagRequired("cloudlet")
|
||||
cmd.MarkFlagRequired("cloudlet-org")
|
||||
cmd.MarkFlagRequired("region")
|
||||
if err := cmd.MarkFlagRequired("org"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cmd.MarkFlagRequired("name"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cmd.MarkFlagRequired("cloudlet"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cmd.MarkFlagRequired("cloudlet-org"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cmd.MarkFlagRequired("region"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional flags for create command
|
||||
createInstanceCmd.Flags().StringVarP(&appName, "app", "a", "", "application name (required)")
|
||||
createInstanceCmd.Flags().StringVarP(&appVersion, "version", "v", "", "application version")
|
||||
createInstanceCmd.Flags().StringVarP(&flavorName, "flavor", "f", "", "flavor name (required)")
|
||||
createInstanceCmd.MarkFlagRequired("app")
|
||||
createInstanceCmd.MarkFlagRequired("flavor")
|
||||
if err := createInstanceCmd.MarkFlagRequired("app"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := createInstanceCmd.MarkFlagRequired("flavor"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
cmd/root.go
42
cmd/root.go
|
|
@ -9,10 +9,12 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
baseURL string
|
||||
username string
|
||||
password string
|
||||
cfgFile string
|
||||
baseURL string
|
||||
username string
|
||||
password string
|
||||
debug bool
|
||||
apiVersion string
|
||||
)
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
|
|
@ -39,18 +41,38 @@ func init() {
|
|||
rootCmd.PersistentFlags().StringVar(&baseURL, "base-url", "", "base URL for the Edge Connect API")
|
||||
rootCmd.PersistentFlags().StringVar(&username, "username", "", "username for authentication")
|
||||
rootCmd.PersistentFlags().StringVar(&password, "password", "", "password for authentication")
|
||||
rootCmd.PersistentFlags().StringVar(&apiVersion, "api-version", "v2", "API version to use (v1 or v2)")
|
||||
rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging")
|
||||
|
||||
viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url"))
|
||||
viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username"))
|
||||
viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password"))
|
||||
if err := viper.BindPFlag("base_url", rootCmd.PersistentFlags().Lookup("base-url")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("api_version", rootCmd.PersistentFlags().Lookup("api-version")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
viper.AutomaticEnv()
|
||||
viper.SetEnvPrefix("EDGE_CONNECT")
|
||||
viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL")
|
||||
viper.BindEnv("username", "EDGE_CONNECT_USERNAME")
|
||||
viper.BindEnv("password", "EDGE_CONNECT_PASSWORD")
|
||||
if err := viper.BindEnv("base_url", "EDGE_CONNECT_BASE_URL"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindEnv("username", "EDGE_CONNECT_USERNAME"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindEnv("password", "EDGE_CONNECT_PASSWORD"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindEnv("api_version", "EDGE_CONNECT_API_VERSION"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if cfgFile != "" {
|
||||
viper.SetConfigFile(cfgFile)
|
||||
|
|
|
|||
25
flake.lock
generated
Normal file
25
flake.lock
generated
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1759733170,
|
||||
"narHash": "sha256-TXnlsVb5Z8HXZ6mZoeOAIwxmvGHp1g4Dw89eLvIwKVI=",
|
||||
"rev": "8913c168d1c56dc49a7718685968f38752171c3b",
|
||||
"revCount": 873256,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.873256%2Brev-8913c168d1c56dc49a7718685968f38752171c3b/0199bd36-8ae7-7817-b019-8688eb4f61ff/source.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://flakehub.com/f/NixOS/nixpkgs/0.1"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
38
flake.nix
Normal file
38
flake.nix
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
description = "A Nix-flake-based Go development environment";
|
||||
|
||||
inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1";
|
||||
|
||||
outputs = inputs:
|
||||
let
|
||||
goVersion = 25; # Change this to update the whole stack
|
||||
|
||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||
forEachSupportedSystem = f: inputs.nixpkgs.lib.genAttrs supportedSystems (system: f {
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ inputs.self.overlays.default ];
|
||||
};
|
||||
});
|
||||
in
|
||||
{
|
||||
overlays.default = final: prev: {
|
||||
go = final."go_1_${toString goVersion}";
|
||||
};
|
||||
|
||||
devShells = forEachSupportedSystem ({ pkgs }: {
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
# go (version is specified by overlay)
|
||||
go
|
||||
|
||||
# goimports, godoc, etc.
|
||||
gotools
|
||||
|
||||
# https://github.com/golangci/golangci-lint
|
||||
golangci-lint
|
||||
];
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
2
go.mod
2
go.mod
|
|
@ -1,4 +1,4 @@
|
|||
module edp.buildth.ing/DevFW-CICD/edge-connect-client
|
||||
module edp.buildth.ing/DevFW-CICD/edge-connect-client/v2
|
||||
|
||||
go 1.25.1
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
// ABOUTME: Resource management for EdgeConnect apply command with deployment execution and rollback
|
||||
// ABOUTME: Handles actual deployment operations, manifest processing, and error recovery with parallel execution
|
||||
package apply
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
// ResourceManagerInterface defines the interface for resource management
|
||||
|
|
@ -265,7 +265,7 @@ func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, acti
|
|||
for _, instanceAction := range plan.InstanceActions {
|
||||
if instanceAction.InstanceName == action.Target {
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: instanceAction.Target.Organization,
|
||||
Organization: plan.AppAction.Desired.Organization,
|
||||
Name: instanceAction.InstanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: instanceAction.Target.CloudletOrg,
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios
|
||||
// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients
|
||||
package apply
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -10,8 +10,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -110,7 +110,6 @@ func createTestDeploymentPlan() *DeploymentPlan {
|
|||
{
|
||||
Type: ActionCreate,
|
||||
Target: config.InfraTemplate{
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
CloudletOrg: "cloudletorg",
|
||||
CloudletName: "cloudlet1",
|
||||
|
|
@ -137,16 +136,16 @@ func createTestManagerConfig(t *testing.T) *config.EdgeConnectConfig {
|
|||
return &config.EdgeConnectConfig{
|
||||
Kind: "edgeconnect-deployment",
|
||||
Metadata: config.Metadata{
|
||||
Name: "test-app",
|
||||
Name: "test-app",
|
||||
AppVersion: "1.0.0",
|
||||
Organization: "testorg",
|
||||
},
|
||||
Spec: config.Spec{
|
||||
K8sApp: &config.K8sApp{
|
||||
AppVersion: "1.0.0",
|
||||
ManifestFile: manifestFile,
|
||||
},
|
||||
InfraTemplate: []config.InfraTemplate{
|
||||
{
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
CloudletOrg: "cloudletorg",
|
||||
CloudletName: "cloudlet1",
|
||||
|
|
@ -309,7 +308,6 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
|
|||
{
|
||||
Type: ActionCreate,
|
||||
Target: config.InfraTemplate{
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
CloudletOrg: "cloudletorg1",
|
||||
CloudletName: "cloudlet1",
|
||||
|
|
@ -321,7 +319,6 @@ func TestApplyDeploymentMultipleInstances(t *testing.T) {
|
|||
{
|
||||
Type: ActionCreate,
|
||||
Target: config.InfraTemplate{
|
||||
Organization: "testorg",
|
||||
Region: "EU",
|
||||
CloudletOrg: "cloudletorg2",
|
||||
CloudletName: "cloudlet2",
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison
|
||||
// ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls
|
||||
package apply
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -11,8 +11,8 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
// EdgeConnectClientInterface defines the methods needed for deployment planning
|
||||
|
|
@ -21,7 +21,7 @@ type EdgeConnectClientInterface interface {
|
|||
CreateApp(ctx context.Context, input *edgeconnect.NewAppInput) error
|
||||
UpdateApp(ctx context.Context, input *edgeconnect.UpdateAppInput) error
|
||||
DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error
|
||||
ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error)
|
||||
ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error)
|
||||
CreateAppInstance(ctx context.Context, input *edgeconnect.NewAppInstanceInput) error
|
||||
UpdateAppInstance(ctx context.Context, input *edgeconnect.UpdateAppInstanceInput) error
|
||||
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error
|
||||
|
|
@ -134,10 +134,10 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E
|
|||
// Build desired app state
|
||||
desired := &AppState{
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Spec.GetAppVersion(),
|
||||
Organization: config.Spec.InfraTemplate[0].Organization, // Use first infra template for org
|
||||
Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region
|
||||
Exists: false, // Will be set based on current state
|
||||
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() {
|
||||
|
|
@ -204,6 +204,7 @@ func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.E
|
|||
action.Type = ActionUpdate
|
||||
action.Changes = changes
|
||||
action.Reason = "Application configuration has changed"
|
||||
fmt.Printf("Changes: %v\n", changes)
|
||||
|
||||
if manifestChanged {
|
||||
warnings = append(warnings, "Manifest file has changed - instances may need to be recreated")
|
||||
|
|
@ -219,12 +220,12 @@ func (p *EdgeConnectPlanner) planInstanceActions(ctx context.Context, config *co
|
|||
var warnings []string
|
||||
|
||||
for _, infra := range config.Spec.InfraTemplate {
|
||||
instanceName := getInstanceName(config.Metadata.Name, config.Spec.GetAppVersion())
|
||||
instanceName := getInstanceName(config.Metadata.Name, config.Metadata.AppVersion)
|
||||
|
||||
desired := &InstanceState{
|
||||
Name: instanceName,
|
||||
AppVersion: config.Spec.GetAppVersion(),
|
||||
Organization: infra.Organization,
|
||||
AppVersion: config.Metadata.AppVersion,
|
||||
Organization: config.Metadata.Organization,
|
||||
Region: infra.Region,
|
||||
CloudletOrg: infra.CloudletOrg,
|
||||
CloudletName: infra.CloudletName,
|
||||
|
|
@ -304,6 +305,11 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
|
|||
LastUpdated: time.Now(), // EdgeConnect doesn't provide this, so use current time
|
||||
}
|
||||
|
||||
// Calculate current manifest hash
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(app.DeploymentManifest))
|
||||
current.ManifestHash = fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
|
||||
// Note: EdgeConnect API doesn't currently support annotations for manifest hash tracking
|
||||
// This would be implemented when the API supports it
|
||||
|
||||
|
|
@ -317,12 +323,7 @@ func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *Ap
|
|||
// Extract outbound connections from the app
|
||||
current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections))
|
||||
for i, conn := range app.RequiredOutboundConnections {
|
||||
current.OutboundConnections[i] = SecurityRule{
|
||||
Protocol: conn.Protocol,
|
||||
PortRangeMin: conn.PortRangeMin,
|
||||
PortRangeMax: conn.PortRangeMax,
|
||||
RemoteCIDR: conn.RemoteCIDR,
|
||||
}
|
||||
current.OutboundConnections[i] = SecurityRule(conn)
|
||||
}
|
||||
|
||||
return current, nil
|
||||
|
|
@ -341,8 +342,11 @@ func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desire
|
|||
Name: desired.CloudletName,
|
||||
},
|
||||
}
|
||||
appKey := edgeconnect.AppKey{
|
||||
Name: desired.AppName,
|
||||
}
|
||||
|
||||
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, desired.Region)
|
||||
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -384,17 +388,25 @@ func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]str
|
|||
}
|
||||
|
||||
// Compare outbound connections
|
||||
if !p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections) {
|
||||
changes = append(changes, "Outbound connections changed")
|
||||
outboundChanges := p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections)
|
||||
if len(outboundChanges) > 0 {
|
||||
sb := strings.Builder{}
|
||||
sb.WriteString("Outbound connections changed:\n")
|
||||
for _, change := range outboundChanges {
|
||||
sb.WriteString(change)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
changes = append(changes, sb.String())
|
||||
}
|
||||
|
||||
return changes, manifestChanged
|
||||
}
|
||||
|
||||
// compareOutboundConnections compares two sets of outbound connections for equality
|
||||
func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) bool {
|
||||
makeMap := func(rules []SecurityRule) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(rules))
|
||||
func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) []string {
|
||||
var changes []string
|
||||
makeMap := func(rules []SecurityRule) map[string]SecurityRule {
|
||||
m := make(map[string]SecurityRule, len(rules))
|
||||
for _, r := range rules {
|
||||
key := fmt.Sprintf("%s:%d-%d:%s",
|
||||
strings.ToLower(r.Protocol),
|
||||
|
|
@ -402,7 +414,7 @@ func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []Secur
|
|||
r.PortRangeMax,
|
||||
r.RemoteCIDR,
|
||||
)
|
||||
m[key] = struct{}{}
|
||||
m[key] = r
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
|
@ -410,17 +422,21 @@ func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []Secur
|
|||
currentMap := makeMap(current)
|
||||
desiredMap := makeMap(desired)
|
||||
|
||||
if len(currentMap) != len(desiredMap) {
|
||||
return false
|
||||
}
|
||||
|
||||
for k := range currentMap {
|
||||
if _, exists := desiredMap[k]; !exists {
|
||||
return false
|
||||
// Find added and modified rules
|
||||
for key, rule := range desiredMap {
|
||||
if _, exists := currentMap[key]; !exists {
|
||||
changes = append(changes, fmt.Sprintf(" - Added outbound connection: %s %d-%d to %s", rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteCIDR))
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
// Find removed rules
|
||||
for key, rule := range currentMap {
|
||||
if _, exists := desiredMap[key]; !exists {
|
||||
changes = append(changes, fmt.Sprintf(" - Removed outbound connection: %s %d-%d to %s", rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteCIDR))
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// compareInstanceStates compares current and desired instance states and returns changes
|
||||
|
|
@ -452,7 +468,9 @@ func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string,
|
|||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open manifest file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
|
|
@ -487,18 +505,20 @@ func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) ti
|
|||
var duration time.Duration
|
||||
|
||||
// App operations
|
||||
if plan.AppAction.Type == ActionCreate {
|
||||
switch plan.AppAction.Type {
|
||||
case ActionCreate:
|
||||
duration += 30 * time.Second
|
||||
} else if plan.AppAction.Type == ActionUpdate {
|
||||
case ActionUpdate:
|
||||
duration += 15 * time.Second
|
||||
}
|
||||
|
||||
// Instance operations (can be done in parallel)
|
||||
instanceDuration := time.Duration(0)
|
||||
for _, action := range plan.InstanceActions {
|
||||
if action.Type == ActionCreate {
|
||||
switch action.Type {
|
||||
case ActionCreate:
|
||||
instanceDuration = max(instanceDuration, 2*time.Minute)
|
||||
} else if action.Type == ActionUpdate {
|
||||
case ActionUpdate:
|
||||
instanceDuration = max(instanceDuration, 1*time.Minute)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios
|
||||
// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios
|
||||
package apply
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -9,8 +9,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -29,7 +29,7 @@ func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey edgeconnect.
|
|||
return args.Get(0).(edgeconnect.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) (edgeconnect.AppInstance, error) {
|
||||
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) (edgeconnect.AppInstance, error) {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return edgeconnect.AppInstance{}, args.Error(1)
|
||||
|
|
@ -75,14 +75,6 @@ func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey edgeconnect
|
|||
return args.Get(0).([]edgeconnect.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) ([]edgeconnect.AppInstance, error) {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]edgeconnect.AppInstance), args.Error(1)
|
||||
}
|
||||
|
||||
func TestNewPlanner(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
|
|
@ -112,16 +104,16 @@ func createTestConfig(t *testing.T) *config.EdgeConnectConfig {
|
|||
return &config.EdgeConnectConfig{
|
||||
Kind: "edgeconnect-deployment",
|
||||
Metadata: config.Metadata{
|
||||
Name: "test-app",
|
||||
Name: "test-app",
|
||||
AppVersion: "1.0.0",
|
||||
Organization: "testorg",
|
||||
},
|
||||
Spec: config.Spec{
|
||||
K8sApp: &config.K8sApp{
|
||||
AppVersion: "1.0.0",
|
||||
ManifestFile: manifestFile,
|
||||
},
|
||||
InfraTemplate: []config.InfraTemplate{
|
||||
{
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
CloudletOrg: "TestCloudletOrg",
|
||||
CloudletName: "TestCloudlet",
|
||||
|
|
@ -185,13 +177,15 @@ func TestPlanExistingDeploymentNoChanges(t *testing.T) {
|
|||
// Note: We would calculate expected manifest hash here when API supports it
|
||||
|
||||
// Mock existing app with same manifest hash and outbound connections
|
||||
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
|
||||
existingApp := &edgeconnect.App{
|
||||
Key: edgeconnect.AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Deployment: "kubernetes",
|
||||
Deployment: "kubernetes",
|
||||
DeploymentManifest: manifestContent,
|
||||
RequiredOutboundConnections: []edgeconnect.SecurityRule{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
|
|
@ -284,7 +278,6 @@ func TestPlanMultipleInfrastructures(t *testing.T) {
|
|||
|
||||
// Add a second infrastructure target
|
||||
testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{
|
||||
Organization: "testorg",
|
||||
Region: "EU",
|
||||
CloudletOrg: "EUCloudletOrg",
|
||||
CloudletName: "EUCloudlet",
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
|
||||
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
|
||||
package apply
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
)
|
||||
|
||||
// DeploymentStrategy represents the type of deployment strategy
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect
|
||||
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
|
||||
package apply
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
// RecreateStrategy implements the recreate deployment strategy
|
||||
|
|
@ -355,6 +356,15 @@ func (r *RecreateStrategy) executeInstanceActionWithRetry(ctx context.Context, a
|
|||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Check if error is retryable (don't retry 4xx client errors)
|
||||
if !isRetryableError(err) {
|
||||
r.logf("Failed to %s instance %s: %v (non-retryable error, giving up)", operation, action.InstanceName, err)
|
||||
result.Error = fmt.Errorf("non-retryable error: %w", err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
if attempt < r.config.MaxRetries {
|
||||
r.logf("Failed to %s instance %s: %v (will retry)", operation, action.InstanceName, err)
|
||||
}
|
||||
|
|
@ -395,6 +405,15 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action
|
|||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Check if error is retryable (don't retry 4xx client errors)
|
||||
if !isRetryableError(err) {
|
||||
r.logf("Failed to update app: %v (non-retryable error, giving up)", err)
|
||||
result.Error = fmt.Errorf("non-retryable error: %w", err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
if attempt < r.config.MaxRetries {
|
||||
r.logf("Failed to update app: %v (will retry)", err)
|
||||
}
|
||||
|
|
@ -408,7 +427,7 @@ func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action
|
|||
// deleteInstance deletes an instance (reuse existing logic from manager.go)
|
||||
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: action.Target.Organization,
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.InstanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: action.Target.CloudletOrg,
|
||||
|
|
@ -430,7 +449,7 @@ func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAc
|
|||
Region: action.Target.Region,
|
||||
AppInst: edgeconnect.AppInstance{
|
||||
Key: edgeconnect.AppInstanceKey{
|
||||
Organization: action.Target.Organization,
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.InstanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: action.Target.CloudletOrg,
|
||||
|
|
@ -438,9 +457,9 @@ func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAc
|
|||
},
|
||||
},
|
||||
AppKey: edgeconnect.AppKey{
|
||||
Organization: action.Target.Organization,
|
||||
Organization: action.Desired.Organization,
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Spec.GetAppVersion(),
|
||||
Version: config.Metadata.AppVersion,
|
||||
},
|
||||
Flavor: edgeconnect.Flavor{
|
||||
Name: action.Target.FlavorName,
|
||||
|
|
@ -503,3 +522,27 @@ func (r *RecreateStrategy) logf(format string, v ...interface{}) {
|
|||
r.logger.Printf("[RecreateStrategy] "+format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
// isRetryableError determines if an error should be retried
|
||||
// Returns false for client errors (4xx), true for server errors (5xx) and other transient errors
|
||||
func isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's an APIError with a status code
|
||||
var apiErr *edgeconnect.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
// Don't retry client errors (4xx)
|
||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
|
||||
return false
|
||||
}
|
||||
// Retry server errors (5xx)
|
||||
if apiErr.StatusCode >= 500 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Retry all other errors (network issues, timeouts, etc.)
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
// ABOUTME: Deployment planning types for EdgeConnect apply command with state management
|
||||
// ABOUTME: Defines structures for deployment plans, actions, and state comparison results
|
||||
package apply
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
// SecurityRule defines network access rules (alias to SDK type for consistency)
|
||||
|
|
@ -353,40 +354,50 @@ func (dp *DeploymentPlan) GenerateSummary() string {
|
|||
return "No changes required - configuration matches current state"
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName))
|
||||
|
||||
// App actions
|
||||
if dp.AppAction.Type != ActionNone {
|
||||
summary += fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name)
|
||||
sb.WriteString(fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name))
|
||||
if len(dp.AppAction.Changes) > 0 {
|
||||
for _, change := range dp.AppAction.Changes {
|
||||
summary += fmt.Sprintf(" - %s\n", change)
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", change))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instance actions
|
||||
createCount := 0
|
||||
updateCount := 0
|
||||
updateActions := []InstanceAction{}
|
||||
for _, action := range dp.InstanceActions {
|
||||
switch action.Type {
|
||||
case ActionCreate:
|
||||
createCount++
|
||||
case ActionUpdate:
|
||||
updateCount++
|
||||
updateActions = append(updateActions, action)
|
||||
}
|
||||
}
|
||||
|
||||
if createCount > 0 {
|
||||
summary += fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets()))
|
||||
}
|
||||
if updateCount > 0 {
|
||||
summary += fmt.Sprintf("- UPDATE %d instance(s)\n", updateCount)
|
||||
sb.WriteString(fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets())))
|
||||
}
|
||||
|
||||
summary += fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String())
|
||||
if len(updateActions) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- UPDATE %d instance(s)\n", len(updateActions)))
|
||||
for _, action := range updateActions {
|
||||
if len(action.Changes) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" - Instance '%s':\n", action.InstanceName))
|
||||
for _, change := range action.Changes {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", change))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Validate checks if the deployment plan is valid and safe to execute
|
||||
434
internal/apply/v2/manager.go
Normal file
434
internal/apply/v2/manager.go
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
// ABOUTME: Resource management for EdgeConnect apply command with deployment execution and rollback
|
||||
// ABOUTME: Handles actual deployment operations, manifest processing, and error recovery with parallel execution
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
// ResourceManagerInterface defines the interface for resource management
|
||||
type ResourceManagerInterface interface {
|
||||
// ApplyDeployment executes a deployment plan
|
||||
ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error)
|
||||
|
||||
// RollbackDeployment attempts to rollback a failed deployment
|
||||
RollbackDeployment(ctx context.Context, result *ExecutionResult) error
|
||||
|
||||
// ValidatePrerequisites checks if deployment prerequisites are met
|
||||
ValidatePrerequisites(ctx context.Context, plan *DeploymentPlan) error
|
||||
}
|
||||
|
||||
// EdgeConnectResourceManager implements resource management for EdgeConnect
|
||||
type EdgeConnectResourceManager struct {
|
||||
client EdgeConnectClientInterface
|
||||
parallelLimit int
|
||||
rollbackOnFail bool
|
||||
logger Logger
|
||||
strategyConfig StrategyConfig
|
||||
}
|
||||
|
||||
// Logger interface for deployment logging
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
// ResourceManagerOptions configures the resource manager behavior
|
||||
type ResourceManagerOptions struct {
|
||||
// ParallelLimit controls how many operations run concurrently
|
||||
ParallelLimit int
|
||||
|
||||
// RollbackOnFail automatically rolls back on deployment failure
|
||||
RollbackOnFail bool
|
||||
|
||||
// Logger for deployment operations
|
||||
Logger Logger
|
||||
|
||||
// Timeout for individual operations
|
||||
OperationTimeout time.Duration
|
||||
|
||||
// StrategyConfig for deployment strategies
|
||||
StrategyConfig StrategyConfig
|
||||
}
|
||||
|
||||
// DefaultResourceManagerOptions returns sensible defaults
|
||||
func DefaultResourceManagerOptions() ResourceManagerOptions {
|
||||
return ResourceManagerOptions{
|
||||
ParallelLimit: 5, // Conservative parallel limit
|
||||
RollbackOnFail: true,
|
||||
OperationTimeout: 2 * time.Minute,
|
||||
StrategyConfig: DefaultStrategyConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewResourceManager creates a new EdgeConnect resource manager
|
||||
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
||||
options := DefaultResourceManagerOptions()
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
return &EdgeConnectResourceManager{
|
||||
client: client,
|
||||
parallelLimit: options.ParallelLimit,
|
||||
rollbackOnFail: options.RollbackOnFail,
|
||||
logger: options.Logger,
|
||||
strategyConfig: options.StrategyConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// WithParallelLimit sets the parallel execution limit
|
||||
func WithParallelLimit(limit int) func(*ResourceManagerOptions) {
|
||||
return func(opts *ResourceManagerOptions) {
|
||||
opts.ParallelLimit = limit
|
||||
}
|
||||
}
|
||||
|
||||
// WithRollbackOnFail enables/disables automatic rollback
|
||||
func WithRollbackOnFail(rollback bool) func(*ResourceManagerOptions) {
|
||||
return func(opts *ResourceManagerOptions) {
|
||||
opts.RollbackOnFail = rollback
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets a logger for deployment operations
|
||||
func WithLogger(logger Logger) func(*ResourceManagerOptions) {
|
||||
return func(opts *ResourceManagerOptions) {
|
||||
opts.Logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// WithStrategyConfig sets the strategy configuration
|
||||
func WithStrategyConfig(config StrategyConfig) func(*ResourceManagerOptions) {
|
||||
return func(opts *ResourceManagerOptions) {
|
||||
opts.StrategyConfig = config
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyDeployment executes a deployment plan using deployment strategies
|
||||
func (rm *EdgeConnectResourceManager) ApplyDeployment(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) {
|
||||
rm.logf("Starting deployment: %s", plan.ConfigName)
|
||||
|
||||
// Step 1: Validate prerequisites
|
||||
if err := rm.ValidatePrerequisites(ctx, plan); err != nil {
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
CompletedActions: []ActionResult{},
|
||||
FailedActions: []ActionResult{},
|
||||
Error: fmt.Errorf("prerequisites validation failed: %w", err),
|
||||
Duration: 0,
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Step 2: Determine deployment strategy
|
||||
strategyName := DeploymentStrategy(config.Spec.GetDeploymentStrategy())
|
||||
rm.logf("Using deployment strategy: %s", strategyName)
|
||||
|
||||
// Step 3: Create strategy executor
|
||||
strategyConfig := rm.strategyConfig
|
||||
strategyConfig.ParallelOperations = rm.parallelLimit > 1
|
||||
|
||||
factory := NewStrategyFactory(rm.client, strategyConfig, rm.logger)
|
||||
strategy, err := factory.CreateStrategy(strategyName)
|
||||
if err != nil {
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
CompletedActions: []ActionResult{},
|
||||
FailedActions: []ActionResult{},
|
||||
Error: fmt.Errorf("failed to create deployment strategy: %w", err),
|
||||
Duration: 0,
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Step 4: Validate strategy can handle this deployment
|
||||
if err := strategy.Validate(plan); err != nil {
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
CompletedActions: []ActionResult{},
|
||||
FailedActions: []ActionResult{},
|
||||
Error: fmt.Errorf("strategy validation failed: %w", err),
|
||||
Duration: 0,
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Step 5: Execute the deployment strategy
|
||||
rm.logf("Estimated deployment duration: %v", strategy.EstimateDuration(plan))
|
||||
result, err := strategy.Execute(ctx, plan, config, manifestContent)
|
||||
|
||||
// Step 6: Handle rollback if needed
|
||||
if err != nil && rm.rollbackOnFail && result != nil {
|
||||
rm.logf("Deployment failed, attempting rollback...")
|
||||
if rollbackErr := rm.RollbackDeployment(ctx, result); rollbackErr != nil {
|
||||
rm.logf("Rollback failed: %v", rollbackErr)
|
||||
} else {
|
||||
result.RollbackPerformed = true
|
||||
result.RollbackSuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil && result.Success {
|
||||
rm.logf("Deployment completed successfully in %v", result.Duration)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ValidatePrerequisites checks if deployment prerequisites are met
|
||||
func (rm *EdgeConnectResourceManager) ValidatePrerequisites(ctx context.Context, plan *DeploymentPlan) error {
|
||||
rm.logf("Validating deployment prerequisites for: %s", plan.ConfigName)
|
||||
|
||||
// Check if we have any actions to perform
|
||||
if plan.IsEmpty() {
|
||||
return fmt.Errorf("deployment plan is empty - no actions to perform")
|
||||
}
|
||||
|
||||
// Validate that we have required client capabilities
|
||||
if rm.client == nil {
|
||||
return fmt.Errorf("EdgeConnect client is not configured")
|
||||
}
|
||||
|
||||
rm.logf("Prerequisites validation passed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackDeployment attempts to rollback a failed deployment
|
||||
func (rm *EdgeConnectResourceManager) RollbackDeployment(ctx context.Context, result *ExecutionResult) error {
|
||||
rm.logf("Starting rollback for deployment: %s", result.Plan.ConfigName)
|
||||
|
||||
rollbackErrors := []error{}
|
||||
|
||||
// Phase 1: Delete resources that were created in this deployment attempt (in reverse order)
|
||||
rm.logf("Phase 1: Rolling back created resources")
|
||||
for i := len(result.CompletedActions) - 1; i >= 0; i-- {
|
||||
action := result.CompletedActions[i]
|
||||
|
||||
switch action.Type {
|
||||
case ActionCreate:
|
||||
if err := rm.rollbackCreateAction(ctx, action, result.Plan); err != nil {
|
||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to rollback %s: %w", action.Target, err))
|
||||
} else {
|
||||
rm.logf("Successfully rolled back: %s", action.Target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Restore resources that were deleted before the failed deployment
|
||||
// This is critical for RecreateStrategy which deletes everything before recreating
|
||||
if result.DeletedAppBackup != nil || len(result.DeletedInstancesBackup) > 0 {
|
||||
rm.logf("Phase 2: Restoring deleted resources")
|
||||
|
||||
// Restore app first (must exist before instances can be created)
|
||||
if result.DeletedAppBackup != nil {
|
||||
if err := rm.restoreApp(ctx, result.DeletedAppBackup); err != nil {
|
||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to restore app: %w", err))
|
||||
rm.logf("Failed to restore app: %v", err)
|
||||
} else {
|
||||
rm.logf("Successfully restored app: %s", result.DeletedAppBackup.App.Key.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore instances
|
||||
for _, backup := range result.DeletedInstancesBackup {
|
||||
if err := rm.restoreInstance(ctx, &backup); err != nil {
|
||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("failed to restore instance %s: %w", backup.Instance.Key.Name, err))
|
||||
rm.logf("Failed to restore instance %s: %v", backup.Instance.Key.Name, err)
|
||||
} else {
|
||||
rm.logf("Successfully restored instance: %s", backup.Instance.Key.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(rollbackErrors) > 0 {
|
||||
return fmt.Errorf("rollback encountered %d errors: %v", len(rollbackErrors), rollbackErrors)
|
||||
}
|
||||
|
||||
rm.logf("Rollback completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollbackCreateAction rolls back a CREATE action by deleting the resource
|
||||
func (rm *EdgeConnectResourceManager) rollbackCreateAction(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
|
||||
if action.Type != ActionCreate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine if this is an app or instance rollback based on the target name
|
||||
isInstance := false
|
||||
for _, instanceAction := range plan.InstanceActions {
|
||||
if instanceAction.InstanceName == action.Target {
|
||||
isInstance = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isInstance {
|
||||
return rm.rollbackInstance(ctx, action, plan)
|
||||
} else {
|
||||
return rm.rollbackApp(ctx, action, plan)
|
||||
}
|
||||
}
|
||||
|
||||
// rollbackApp deletes an application that was created
|
||||
func (rm *EdgeConnectResourceManager) rollbackApp(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
|
||||
appKey := v2.AppKey{
|
||||
Organization: plan.AppAction.Desired.Organization,
|
||||
Name: plan.AppAction.Desired.Name,
|
||||
Version: plan.AppAction.Desired.Version,
|
||||
}
|
||||
|
||||
return rm.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region)
|
||||
}
|
||||
|
||||
// rollbackInstance deletes an instance that was created
|
||||
func (rm *EdgeConnectResourceManager) rollbackInstance(ctx context.Context, action ActionResult, plan *DeploymentPlan) error {
|
||||
// Find the instance action to get the details
|
||||
for _, instanceAction := range plan.InstanceActions {
|
||||
if instanceAction.InstanceName == action.Target {
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: plan.AppAction.Desired.Organization,
|
||||
Name: instanceAction.InstanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: instanceAction.Target.CloudletOrg,
|
||||
Name: instanceAction.Target.CloudletName,
|
||||
},
|
||||
}
|
||||
return rm.client.DeleteAppInstance(ctx, instanceKey, instanceAction.Target.Region)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("instance action not found for rollback: %s", action.Target)
|
||||
}
|
||||
|
||||
// restoreApp recreates an app that was deleted during deployment
|
||||
func (rm *EdgeConnectResourceManager) restoreApp(ctx context.Context, backup *AppBackup) error {
|
||||
rm.logf("Restoring app: %s/%s version %s",
|
||||
backup.App.Key.Organization, backup.App.Key.Name, backup.App.Key.Version)
|
||||
|
||||
// Build a clean app input with only creation-safe fields
|
||||
// We must exclude read-only fields like CreatedAt, UpdatedAt, etc.
|
||||
appInput := &v2.NewAppInput{
|
||||
Region: backup.Region,
|
||||
App: v2.App{
|
||||
Key: backup.App.Key,
|
||||
Deployment: backup.App.Deployment,
|
||||
ImageType: backup.App.ImageType,
|
||||
ImagePath: backup.App.ImagePath,
|
||||
AllowServerless: backup.App.AllowServerless,
|
||||
DefaultFlavor: backup.App.DefaultFlavor,
|
||||
ServerlessConfig: backup.App.ServerlessConfig,
|
||||
DeploymentManifest: backup.App.DeploymentManifest,
|
||||
DeploymentGenerator: backup.App.DeploymentGenerator,
|
||||
RequiredOutboundConnections: backup.App.RequiredOutboundConnections,
|
||||
// Explicitly omit read-only fields like CreatedAt, UpdatedAt, Fields, etc.
|
||||
},
|
||||
}
|
||||
|
||||
if err := rm.client.CreateApp(ctx, appInput); err != nil {
|
||||
return fmt.Errorf("failed to restore app: %w", err)
|
||||
}
|
||||
|
||||
rm.logf("Successfully restored app: %s", backup.App.Key.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreInstance recreates an instance that was deleted during deployment
|
||||
func (rm *EdgeConnectResourceManager) restoreInstance(ctx context.Context, backup *InstanceBackup) error {
|
||||
rm.logf("Restoring instance: %s on %s:%s",
|
||||
backup.Instance.Key.Name,
|
||||
backup.Instance.Key.CloudletKey.Organization,
|
||||
backup.Instance.Key.CloudletKey.Name)
|
||||
|
||||
// Build a clean instance input with only creation-safe fields
|
||||
// We must exclude read-only fields like CloudletLoc, CreatedAt, etc.
|
||||
instanceInput := &v2.NewAppInstanceInput{
|
||||
Region: backup.Region,
|
||||
AppInst: v2.AppInstance{
|
||||
Key: backup.Instance.Key,
|
||||
AppKey: backup.Instance.AppKey,
|
||||
Flavor: backup.Instance.Flavor,
|
||||
// Explicitly omit read-only fields like CloudletLoc, State, PowerState, CreatedAt, etc.
|
||||
},
|
||||
}
|
||||
|
||||
// Retry logic to handle namespace termination race conditions
|
||||
maxRetries := 5
|
||||
retryDelay := 10 * time.Second
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
rm.logf("Retrying instance restore %s (attempt %d/%d)", backup.Instance.Key.Name, attempt, maxRetries)
|
||||
select {
|
||||
case <-time.After(retryDelay):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err := rm.client.CreateAppInstance(ctx, instanceInput)
|
||||
if err == nil {
|
||||
rm.logf("Successfully restored instance: %s", backup.Instance.Key.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Check if error is retryable
|
||||
if !rm.isRetryableError(err) {
|
||||
rm.logf("Failed to restore instance %s: %v (non-retryable error, giving up)", backup.Instance.Key.Name, err)
|
||||
return fmt.Errorf("failed to restore instance: %w", err)
|
||||
}
|
||||
|
||||
if attempt < maxRetries {
|
||||
rm.logf("Failed to restore instance %s: %v (will retry)", backup.Instance.Key.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to restore instance after %d attempts: %w", maxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// isRetryableError determines if an error should be retried
|
||||
func (rm *EdgeConnectResourceManager) isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Special case: Kubernetes namespace termination race condition
|
||||
// This is a transient 400 error that should be retried
|
||||
if strings.Contains(errStr, "being terminated") || strings.Contains(errStr, "is being terminated") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if it's an APIError with a status code
|
||||
var apiErr *v2.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
// Don't retry client errors (4xx)
|
||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
|
||||
return false
|
||||
}
|
||||
// Retry server errors (5xx)
|
||||
if apiErr.StatusCode >= 500 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Retry all other errors (network issues, timeouts, etc.)
|
||||
return true
|
||||
}
|
||||
|
||||
// logf logs a message if a logger is configured
|
||||
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
|
||||
if rm.logger != nil {
|
||||
rm.logger.Printf("[ResourceManager] "+format, v...)
|
||||
}
|
||||
}
|
||||
603
internal/apply/v2/manager_test.go
Normal file
603
internal/apply/v2/manager_test.go
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
// ABOUTME: Comprehensive tests for EdgeConnect resource manager with deployment scenarios
|
||||
// ABOUTME: Tests deployment execution, rollback functionality, and error handling with mock clients
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockResourceClient extends MockEdgeConnectClient with resource management methods
|
||||
type MockResourceClient struct {
|
||||
MockEdgeConnectClient
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) CreateApp(ctx context.Context, input *v2.NewAppInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// TestLogger implements Logger interface for testing
|
||||
type TestLogger struct {
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (l *TestLogger) Printf(format string, v ...interface{}) {
|
||||
l.messages = append(l.messages, fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
func TestNewResourceManager(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
|
||||
assert.NotNil(t, manager)
|
||||
assert.IsType(t, &EdgeConnectResourceManager{}, manager)
|
||||
}
|
||||
|
||||
func TestDefaultResourceManagerOptions(t *testing.T) {
|
||||
opts := DefaultResourceManagerOptions()
|
||||
|
||||
assert.Equal(t, 5, opts.ParallelLimit)
|
||||
assert.True(t, opts.RollbackOnFail)
|
||||
assert.Equal(t, 2*time.Minute, opts.OperationTimeout)
|
||||
}
|
||||
|
||||
func TestWithOptions(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
|
||||
manager := NewResourceManager(mockClient,
|
||||
WithParallelLimit(10),
|
||||
WithRollbackOnFail(false),
|
||||
WithLogger(logger),
|
||||
)
|
||||
|
||||
// Cast to implementation to check options were applied
|
||||
impl := manager.(*EdgeConnectResourceManager)
|
||||
assert.Equal(t, 10, impl.parallelLimit)
|
||||
assert.False(t, impl.rollbackOnFail)
|
||||
assert.Equal(t, logger, impl.logger)
|
||||
}
|
||||
|
||||
func createTestDeploymentPlan() *DeploymentPlan {
|
||||
return &DeploymentPlan{
|
||||
ConfigName: "test-deployment",
|
||||
AppAction: AppAction{
|
||||
Type: ActionCreate,
|
||||
Desired: &AppState{
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
},
|
||||
},
|
||||
InstanceActions: []InstanceAction{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: config.InfraTemplate{
|
||||
Region: "US",
|
||||
CloudletOrg: "cloudletorg",
|
||||
CloudletName: "cloudlet1",
|
||||
FlavorName: "small",
|
||||
},
|
||||
Desired: &InstanceState{
|
||||
Name: "test-app-1.0.0-instance",
|
||||
AppName: "test-app",
|
||||
},
|
||||
InstanceName: "test-app-1.0.0-instance",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestManagerConfig(t *testing.T) *config.EdgeConnectConfig {
|
||||
// Create temporary manifest file
|
||||
tempDir := t.TempDir()
|
||||
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
|
||||
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
|
||||
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &config.EdgeConnectConfig{
|
||||
Kind: "edgeconnect-deployment",
|
||||
Metadata: config.Metadata{
|
||||
Name: "test-app",
|
||||
AppVersion: "1.0.0",
|
||||
Organization: "testorg",
|
||||
},
|
||||
Spec: config.Spec{
|
||||
K8sApp: &config.K8sApp{
|
||||
ManifestFile: manifestFile,
|
||||
},
|
||||
InfraTemplate: []config.InfraTemplate{
|
||||
{
|
||||
Region: "US",
|
||||
CloudletOrg: "cloudletorg",
|
||||
CloudletName: "cloudlet1",
|
||||
FlavorName: "small",
|
||||
},
|
||||
},
|
||||
Network: &config.NetworkConfig{
|
||||
OutboundConnections: []config.OutboundConnection{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
PortRangeMax: 80,
|
||||
RemoteCIDR: "0.0.0.0/0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createTestStrategyConfig returns a fast configuration for tests
|
||||
func createTestStrategyConfig() StrategyConfig {
|
||||
return StrategyConfig{
|
||||
MaxRetries: 0, // No retries for fast tests
|
||||
HealthCheckTimeout: 1 * time.Millisecond,
|
||||
ParallelOperations: false, // Sequential for predictable tests
|
||||
RetryDelay: 0, // No delay
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeploymentSuccess(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
// Mock successful operations
|
||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
|
||||
Return(nil)
|
||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 2) // 1 app + 1 instance
|
||||
assert.Len(t, result.FailedActions, 0)
|
||||
assert.False(t, result.RollbackPerformed)
|
||||
assert.Greater(t, result.Duration, time.Duration(0))
|
||||
|
||||
// Check that operations were logged
|
||||
assert.Greater(t, len(logger.messages), 0)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestApplyDeploymentAppFailure(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
// Mock app creation failure - deployment should stop here
|
||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
|
||||
Return(&v2.APIError{StatusCode: 500, Messages: []string{"Server error"}})
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 0)
|
||||
assert.Len(t, result.FailedActions, 1)
|
||||
assert.Contains(t, err.Error(), "Server error")
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestApplyDeploymentInstanceFailureWithRollback(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithRollbackOnFail(true), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
// Mock successful app creation but failed instance creation
|
||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
|
||||
Return(nil)
|
||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
|
||||
Return(&v2.APIError{StatusCode: 500, Messages: []string{"Instance creation failed"}})
|
||||
|
||||
// Mock rollback operations
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 1) // App was created
|
||||
assert.Len(t, result.FailedActions, 1) // Instance failed
|
||||
assert.True(t, result.RollbackPerformed)
|
||||
assert.True(t, result.RollbackSuccess)
|
||||
assert.Contains(t, err.Error(), "failed to create instance")
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestApplyDeploymentNoActions(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
|
||||
// Create empty plan
|
||||
plan := &DeploymentPlan{
|
||||
ConfigName: "empty-plan",
|
||||
AppAction: AppAction{Type: ActionNone},
|
||||
}
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Contains(t, err.Error(), "deployment plan is empty")
|
||||
|
||||
mockClient.AssertNotCalled(t, "CreateApp")
|
||||
mockClient.AssertNotCalled(t, "CreateAppInstance")
|
||||
}
|
||||
|
||||
func TestApplyDeploymentMultipleInstances(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithParallelLimit(2), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
// Create plan with multiple instances
|
||||
plan := &DeploymentPlan{
|
||||
ConfigName: "multi-instance",
|
||||
AppAction: AppAction{
|
||||
Type: ActionCreate,
|
||||
Desired: &AppState{
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
},
|
||||
},
|
||||
InstanceActions: []InstanceAction{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: config.InfraTemplate{
|
||||
Region: "US",
|
||||
CloudletOrg: "cloudletorg1",
|
||||
CloudletName: "cloudlet1",
|
||||
FlavorName: "small",
|
||||
},
|
||||
Desired: &InstanceState{Name: "instance1"},
|
||||
InstanceName: "instance1",
|
||||
},
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: config.InfraTemplate{
|
||||
Region: "EU",
|
||||
CloudletOrg: "cloudletorg2",
|
||||
CloudletName: "cloudlet2",
|
||||
FlavorName: "medium",
|
||||
},
|
||||
Desired: &InstanceState{Name: "instance2"},
|
||||
InstanceName: "instance2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config := createTestManagerConfig(t)
|
||||
|
||||
// Mock successful operations
|
||||
mockClient.On("CreateApp", mock.Anything, mock.AnythingOfType("*v2.NewAppInput")).
|
||||
Return(nil)
|
||||
mockClient.On("CreateAppInstance", mock.Anything, mock.AnythingOfType("*v2.NewAppInstanceInput")).
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ApplyDeployment(ctx, plan, config, "test manifest content")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 3) // 1 app + 2 instances
|
||||
assert.Len(t, result.FailedActions, 0)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestValidatePrerequisites(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
plan *DeploymentPlan
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid plan",
|
||||
plan: &DeploymentPlan{
|
||||
ConfigName: "test",
|
||||
AppAction: AppAction{Type: ActionCreate, Desired: &AppState{}},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty plan",
|
||||
plan: &DeploymentPlan{
|
||||
ConfigName: "test",
|
||||
AppAction: AppAction{Type: ActionNone},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "deployment plan is empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := manager.ValidatePrerequisites(ctx, tt.plan)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackDeployment(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
// Create result with completed actions
|
||||
plan := createTestDeploymentPlan()
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
CompletedActions: []ActionResult{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: "test-app",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: "test-app-1.0.0-instance",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
FailedActions: []ActionResult{},
|
||||
}
|
||||
|
||||
// Mock rollback operations
|
||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(nil)
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
err := manager.RollbackDeployment(ctx, result)
|
||||
|
||||
require.NoError(t, err)
|
||||
mockClient.AssertExpectations(t)
|
||||
|
||||
// Check rollback was logged
|
||||
assert.Greater(t, len(logger.messages), 0)
|
||||
}
|
||||
|
||||
func TestRollbackDeploymentFailure(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
CompletedActions: []ActionResult{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: "test-app",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock rollback failure
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(&v2.APIError{StatusCode: 500, Messages: []string{"Delete failed"}})
|
||||
|
||||
ctx := context.Background()
|
||||
err := manager.RollbackDeployment(ctx, result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "rollback encountered")
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestRollbackDeploymentWithRestore(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger), WithStrategyConfig(createTestStrategyConfig()))
|
||||
|
||||
plan := createTestDeploymentPlan()
|
||||
|
||||
// Simulate a RecreateStrategy scenario:
|
||||
// 1. Old app and instance were deleted and backed up
|
||||
// 2. New app was created successfully
|
||||
// 3. New instance creation failed
|
||||
// 4. Rollback should: delete new app, restore old app, restore old instance
|
||||
oldApp := v2.App{
|
||||
Key: v2.AppKey{
|
||||
Organization: "test-org",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Deployment: "kubernetes",
|
||||
DeploymentManifest: "old-manifest-content",
|
||||
}
|
||||
|
||||
oldInstance := v2.AppInstance{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: "test-org",
|
||||
Name: "test-app-1.0.0-instance",
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: "test-cloudlet-org",
|
||||
Name: "test-cloudlet",
|
||||
},
|
||||
},
|
||||
AppKey: v2.AppKey{
|
||||
Organization: "test-org",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Flavor: v2.Flavor{Name: "small"},
|
||||
}
|
||||
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
// Completed actions: new app was created before failure
|
||||
CompletedActions: []ActionResult{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: "test-app",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
// Failed action: new instance creation failed
|
||||
FailedActions: []ActionResult{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: "test-app-1.0.0-instance",
|
||||
Success: false,
|
||||
},
|
||||
},
|
||||
// Backup of deleted resources
|
||||
DeletedAppBackup: &AppBackup{
|
||||
App: oldApp,
|
||||
Region: "US",
|
||||
ManifestContent: "old-manifest-content",
|
||||
},
|
||||
DeletedInstancesBackup: []InstanceBackup{
|
||||
{
|
||||
Instance: oldInstance,
|
||||
Region: "US",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock rollback operations in order:
|
||||
// 1. Delete newly created app (rollback create)
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(nil).Once()
|
||||
|
||||
// 2. Restore old app (from backup)
|
||||
mockClient.On("CreateApp", mock.Anything, mock.MatchedBy(func(input *v2.NewAppInput) bool {
|
||||
return input.App.Key.Name == "test-app" && input.App.DeploymentManifest == "old-manifest-content"
|
||||
})).Return(nil).Once()
|
||||
|
||||
// 3. Restore old instance (from backup)
|
||||
mockClient.On("CreateAppInstance", mock.Anything, mock.MatchedBy(func(input *v2.NewAppInstanceInput) bool {
|
||||
return input.AppInst.Key.Name == "test-app-1.0.0-instance"
|
||||
})).Return(nil).Once()
|
||||
|
||||
ctx := context.Background()
|
||||
err := manager.RollbackDeployment(ctx, result)
|
||||
|
||||
require.NoError(t, err)
|
||||
mockClient.AssertExpectations(t)
|
||||
|
||||
// Verify rollback was logged
|
||||
assert.Greater(t, len(logger.messages), 0)
|
||||
// Should have messages about rolling back created resources and restoring deleted resources
|
||||
hasRestoreLog := false
|
||||
for _, msg := range logger.messages {
|
||||
if strings.Contains(msg, "Restoring deleted resources") {
|
||||
hasRestoreLog = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasRestoreLog, "Should log restoration of deleted resources")
|
||||
}
|
||||
|
||||
func TestConvertNetworkRules(t *testing.T) {
|
||||
network := &config.NetworkConfig{
|
||||
OutboundConnections: []config.OutboundConnection{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
PortRangeMax: 80,
|
||||
RemoteCIDR: "0.0.0.0/0",
|
||||
},
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 443,
|
||||
PortRangeMax: 443,
|
||||
RemoteCIDR: "10.0.0.0/8",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rules := convertNetworkRules(network)
|
||||
require.Len(t, rules, 2)
|
||||
|
||||
assert.Equal(t, "tcp", rules[0].Protocol)
|
||||
assert.Equal(t, 80, rules[0].PortRangeMin)
|
||||
assert.Equal(t, 80, rules[0].PortRangeMax)
|
||||
assert.Equal(t, "0.0.0.0/0", rules[0].RemoteCIDR)
|
||||
|
||||
assert.Equal(t, "tcp", rules[1].Protocol)
|
||||
assert.Equal(t, 443, rules[1].PortRangeMin)
|
||||
assert.Equal(t, 443, rules[1].PortRangeMax)
|
||||
assert.Equal(t, "10.0.0.0/8", rules[1].RemoteCIDR)
|
||||
}
|
||||
556
internal/apply/v2/planner.go
Normal file
556
internal/apply/v2/planner.go
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
// ABOUTME: Deployment planner for EdgeConnect apply command with intelligent state comparison
|
||||
// ABOUTME: Analyzes desired vs current state to generate optimal deployment plans with minimal API calls
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
// EdgeConnectClientInterface defines the methods needed for deployment planning
|
||||
type EdgeConnectClientInterface interface {
|
||||
ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error)
|
||||
CreateApp(ctx context.Context, input *v2.NewAppInput) error
|
||||
UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error
|
||||
DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error
|
||||
ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) (v2.AppInstance, error)
|
||||
CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error
|
||||
UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error
|
||||
DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error
|
||||
}
|
||||
|
||||
// Planner defines the interface for deployment planning
|
||||
type Planner interface {
|
||||
// Plan analyzes the configuration and current state to generate a deployment plan
|
||||
Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error)
|
||||
|
||||
// PlanWithOptions allows customization of planning behavior
|
||||
PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error)
|
||||
}
|
||||
|
||||
// PlanOptions provides configuration for the planning process
|
||||
type PlanOptions struct {
|
||||
// DryRun indicates this is a planning-only operation
|
||||
DryRun bool
|
||||
|
||||
// Force indicates to proceed even with warnings
|
||||
Force bool
|
||||
|
||||
// SkipStateCheck bypasses current state queries (useful for testing)
|
||||
SkipStateCheck bool
|
||||
|
||||
// ParallelQueries enables parallel state fetching
|
||||
ParallelQueries bool
|
||||
|
||||
// Timeout for API operations
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultPlanOptions returns sensible default planning options
|
||||
func DefaultPlanOptions() PlanOptions {
|
||||
return PlanOptions{
|
||||
DryRun: false,
|
||||
Force: false,
|
||||
SkipStateCheck: false,
|
||||
ParallelQueries: true,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
|
||||
type EdgeConnectPlanner struct {
|
||||
client EdgeConnectClientInterface
|
||||
}
|
||||
|
||||
// NewPlanner creates a new EdgeConnect deployment planner
|
||||
func NewPlanner(client EdgeConnectClientInterface) Planner {
|
||||
return &EdgeConnectPlanner{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// Plan analyzes the configuration and generates a deployment plan
|
||||
func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) {
|
||||
return p.PlanWithOptions(ctx, config, DefaultPlanOptions())
|
||||
}
|
||||
|
||||
// PlanWithOptions generates a deployment plan with custom options
|
||||
func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) {
|
||||
startTime := time.Now()
|
||||
var warnings []string
|
||||
|
||||
// Create the deployment plan structure
|
||||
plan := &DeploymentPlan{
|
||||
ConfigName: config.Metadata.Name,
|
||||
CreatedAt: startTime,
|
||||
DryRun: opts.DryRun,
|
||||
}
|
||||
|
||||
// Step 1: Plan application state
|
||||
appAction, appWarnings, err := p.planAppAction(ctx, config, opts)
|
||||
if err != nil {
|
||||
return &PlanResult{Error: err}, err
|
||||
}
|
||||
plan.AppAction = *appAction
|
||||
warnings = append(warnings, appWarnings...)
|
||||
|
||||
// Step 2: Plan instance actions
|
||||
instanceActions, instanceWarnings, err := p.planInstanceActions(ctx, config, opts)
|
||||
if err != nil {
|
||||
return &PlanResult{Error: err}, err
|
||||
}
|
||||
plan.InstanceActions = instanceActions
|
||||
warnings = append(warnings, instanceWarnings...)
|
||||
|
||||
// Step 3: Calculate plan metadata
|
||||
p.calculatePlanMetadata(plan)
|
||||
|
||||
// Step 4: Generate summary
|
||||
plan.Summary = plan.GenerateSummary()
|
||||
|
||||
// Step 5: Validate the plan
|
||||
if err := plan.Validate(); err != nil {
|
||||
return &PlanResult{Error: fmt.Errorf("invalid deployment plan: %w", err)}, err
|
||||
}
|
||||
|
||||
return &PlanResult{
|
||||
Plan: plan,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// planAppAction determines what action needs to be taken for the application
|
||||
func (p *EdgeConnectPlanner) planAppAction(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*AppAction, []string, error) {
|
||||
var warnings []string
|
||||
|
||||
// Build desired app state
|
||||
desired := &AppState{
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Metadata.AppVersion,
|
||||
Organization: config.Metadata.Organization, // Use first infra template for org
|
||||
Region: config.Spec.InfraTemplate[0].Region, // Use first infra template for region
|
||||
Exists: false, // Will be set based on current state
|
||||
}
|
||||
|
||||
if config.Spec.IsK8sApp() {
|
||||
desired.AppType = AppTypeK8s
|
||||
} else {
|
||||
desired.AppType = AppTypeDocker
|
||||
}
|
||||
|
||||
// Extract outbound connections from config
|
||||
if config.Spec.Network != nil {
|
||||
desired.OutboundConnections = make([]SecurityRule, len(config.Spec.Network.OutboundConnections))
|
||||
for i, conn := range config.Spec.Network.OutboundConnections {
|
||||
desired.OutboundConnections[i] = SecurityRule{
|
||||
Protocol: conn.Protocol,
|
||||
PortRangeMin: conn.PortRangeMin,
|
||||
PortRangeMax: conn.PortRangeMax,
|
||||
RemoteCIDR: conn.RemoteCIDR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate manifest hash
|
||||
manifestHash, err := p.calculateManifestHash(config.Spec.GetManifestFile())
|
||||
if err != nil {
|
||||
return nil, warnings, fmt.Errorf("failed to calculate manifest hash: %w", err)
|
||||
}
|
||||
desired.ManifestHash = manifestHash
|
||||
|
||||
action := &AppAction{
|
||||
Type: ActionNone,
|
||||
Desired: desired,
|
||||
ManifestHash: manifestHash,
|
||||
Reason: "No action needed",
|
||||
}
|
||||
|
||||
// Skip state check if requested (useful for testing)
|
||||
if opts.SkipStateCheck {
|
||||
action.Type = ActionCreate
|
||||
action.Reason = "Creating app (state check skipped)"
|
||||
action.Changes = []string{"Create new application"}
|
||||
return action, warnings, nil
|
||||
}
|
||||
|
||||
// Query current app state
|
||||
current, err := p.getCurrentAppState(ctx, desired, opts.Timeout)
|
||||
if err != nil {
|
||||
// If app doesn't exist, we need to create it
|
||||
if isResourceNotFoundError(err) {
|
||||
action.Type = ActionCreate
|
||||
action.Reason = "Application does not exist"
|
||||
action.Changes = []string{"Create new application"}
|
||||
return action, warnings, nil
|
||||
}
|
||||
return nil, warnings, fmt.Errorf("failed to query current app state: %w", err)
|
||||
}
|
||||
|
||||
action.Current = current
|
||||
|
||||
// Compare current vs desired state
|
||||
changes, manifestChanged := p.compareAppStates(current, desired)
|
||||
action.ManifestChanged = manifestChanged
|
||||
|
||||
if len(changes) > 0 {
|
||||
action.Type = ActionUpdate
|
||||
action.Changes = changes
|
||||
action.Reason = "Application configuration has changed"
|
||||
fmt.Printf("Changes: %v\n", changes)
|
||||
|
||||
if manifestChanged {
|
||||
warnings = append(warnings, "Manifest file has changed - instances may need to be recreated")
|
||||
}
|
||||
}
|
||||
|
||||
return action, warnings, nil
|
||||
}
|
||||
|
||||
// planInstanceActions determines what actions need to be taken for instances
|
||||
func (p *EdgeConnectPlanner) planInstanceActions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) ([]InstanceAction, []string, error) {
|
||||
var actions []InstanceAction
|
||||
var warnings []string
|
||||
|
||||
for _, infra := range config.Spec.InfraTemplate {
|
||||
instanceName := getInstanceName(config.Metadata.Name, config.Metadata.AppVersion)
|
||||
|
||||
desired := &InstanceState{
|
||||
Name: instanceName,
|
||||
AppVersion: config.Metadata.AppVersion,
|
||||
Organization: config.Metadata.Organization,
|
||||
Region: infra.Region,
|
||||
CloudletOrg: infra.CloudletOrg,
|
||||
CloudletName: infra.CloudletName,
|
||||
FlavorName: infra.FlavorName,
|
||||
Exists: false,
|
||||
}
|
||||
|
||||
action := &InstanceAction{
|
||||
Type: ActionNone,
|
||||
Target: infra,
|
||||
Desired: desired,
|
||||
InstanceName: instanceName,
|
||||
Reason: "No action needed",
|
||||
}
|
||||
|
||||
// Skip state check if requested
|
||||
if opts.SkipStateCheck {
|
||||
action.Type = ActionCreate
|
||||
action.Reason = "Creating instance (state check skipped)"
|
||||
action.Changes = []string{"Create new instance"}
|
||||
actions = append(actions, *action)
|
||||
continue
|
||||
}
|
||||
|
||||
// Query current instance state
|
||||
current, err := p.getCurrentInstanceState(ctx, desired, opts.Timeout)
|
||||
if err != nil {
|
||||
// If instance doesn't exist, we need to create it
|
||||
if isResourceNotFoundError(err) {
|
||||
action.Type = ActionCreate
|
||||
action.Reason = "Instance does not exist"
|
||||
action.Changes = []string{"Create new instance"}
|
||||
actions = append(actions, *action)
|
||||
continue
|
||||
}
|
||||
return nil, warnings, fmt.Errorf("failed to query current instance state: %w", err)
|
||||
}
|
||||
|
||||
action.Current = current
|
||||
|
||||
// Compare current vs desired state
|
||||
changes := p.compareInstanceStates(current, desired)
|
||||
if len(changes) > 0 {
|
||||
action.Type = ActionUpdate
|
||||
action.Changes = changes
|
||||
action.Reason = "Instance configuration has changed"
|
||||
}
|
||||
|
||||
actions = append(actions, *action)
|
||||
}
|
||||
|
||||
return actions, warnings, nil
|
||||
}
|
||||
|
||||
// getCurrentAppState queries the current state of an application
|
||||
func (p *EdgeConnectPlanner) getCurrentAppState(ctx context.Context, desired *AppState, timeout time.Duration) (*AppState, error) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
appKey := v2.AppKey{
|
||||
Organization: desired.Organization,
|
||||
Name: desired.Name,
|
||||
Version: desired.Version,
|
||||
}
|
||||
|
||||
app, err := p.client.ShowApp(timeoutCtx, appKey, desired.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
current := &AppState{
|
||||
Name: app.Key.Name,
|
||||
Version: app.Key.Version,
|
||||
Organization: app.Key.Organization,
|
||||
Region: desired.Region,
|
||||
Exists: true,
|
||||
LastUpdated: time.Now(), // EdgeConnect doesn't provide this, so use current time
|
||||
}
|
||||
|
||||
// Calculate current manifest hash
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(app.DeploymentManifest))
|
||||
current.ManifestHash = fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
|
||||
// Note: EdgeConnect API doesn't currently support annotations for manifest hash tracking
|
||||
// This would be implemented when the API supports it
|
||||
|
||||
// Determine app type based on deployment type
|
||||
if app.Deployment == "kubernetes" {
|
||||
current.AppType = AppTypeK8s
|
||||
} else {
|
||||
current.AppType = AppTypeDocker
|
||||
}
|
||||
|
||||
// Extract outbound connections from the app
|
||||
current.OutboundConnections = make([]SecurityRule, len(app.RequiredOutboundConnections))
|
||||
for i, conn := range app.RequiredOutboundConnections {
|
||||
current.OutboundConnections[i] = SecurityRule(conn)
|
||||
}
|
||||
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// getCurrentInstanceState queries the current state of an application instance
|
||||
func (p *EdgeConnectPlanner) getCurrentInstanceState(ctx context.Context, desired *InstanceState, timeout time.Duration) (*InstanceState, error) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: desired.Organization,
|
||||
Name: desired.Name,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: desired.CloudletOrg,
|
||||
Name: desired.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
appKey := v2.AppKey{Name: desired.AppName}
|
||||
|
||||
instance, err := p.client.ShowAppInstance(timeoutCtx, instanceKey, appKey, desired.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
current := &InstanceState{
|
||||
Name: instance.Key.Name,
|
||||
AppName: instance.AppKey.Name,
|
||||
AppVersion: instance.AppKey.Version,
|
||||
Organization: instance.Key.Organization,
|
||||
Region: desired.Region,
|
||||
CloudletOrg: instance.Key.CloudletKey.Organization,
|
||||
CloudletName: instance.Key.CloudletKey.Name,
|
||||
FlavorName: instance.Flavor.Name,
|
||||
State: instance.State,
|
||||
PowerState: instance.PowerState,
|
||||
Exists: true,
|
||||
LastUpdated: time.Now(), // EdgeConnect doesn't provide this
|
||||
}
|
||||
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// compareAppStates compares current and desired app states and returns changes
|
||||
func (p *EdgeConnectPlanner) compareAppStates(current, desired *AppState) ([]string, bool) {
|
||||
var changes []string
|
||||
manifestChanged := false
|
||||
|
||||
// Compare manifest hash - only if both states have hash values
|
||||
// Since EdgeConnect API doesn't support annotations yet, skip manifest hash comparison for now
|
||||
// This would be implemented when the API supports manifest hash tracking
|
||||
if current.ManifestHash != "" && desired.ManifestHash != "" && current.ManifestHash != desired.ManifestHash {
|
||||
changes = append(changes, fmt.Sprintf("Manifest hash changed: %s -> %s", current.ManifestHash, desired.ManifestHash))
|
||||
manifestChanged = true
|
||||
}
|
||||
|
||||
// Compare app type
|
||||
if current.AppType != desired.AppType {
|
||||
changes = append(changes, fmt.Sprintf("App type changed: %s -> %s", current.AppType, desired.AppType))
|
||||
}
|
||||
|
||||
// Compare outbound connections
|
||||
outboundChanges := p.compareOutboundConnections(current.OutboundConnections, desired.OutboundConnections)
|
||||
if len(outboundChanges) > 0 {
|
||||
sb := strings.Builder{}
|
||||
sb.WriteString("Outbound connections changed:\n")
|
||||
for _, change := range outboundChanges {
|
||||
sb.WriteString(change)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
changes = append(changes, sb.String())
|
||||
}
|
||||
|
||||
return changes, manifestChanged
|
||||
}
|
||||
|
||||
// compareOutboundConnections compares two sets of outbound connections for equality
|
||||
func (p *EdgeConnectPlanner) compareOutboundConnections(current, desired []SecurityRule) []string {
|
||||
var changes []string
|
||||
makeMap := func(rules []SecurityRule) map[string]SecurityRule {
|
||||
m := make(map[string]SecurityRule, len(rules))
|
||||
for _, r := range rules {
|
||||
key := fmt.Sprintf("%s:%d-%d:%s",
|
||||
strings.ToLower(r.Protocol),
|
||||
r.PortRangeMin,
|
||||
r.PortRangeMax,
|
||||
r.RemoteCIDR,
|
||||
)
|
||||
m[key] = r
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
currentMap := makeMap(current)
|
||||
desiredMap := makeMap(desired)
|
||||
|
||||
// Find added and modified rules
|
||||
for key, rule := range desiredMap {
|
||||
if _, exists := currentMap[key]; !exists {
|
||||
changes = append(changes, fmt.Sprintf(" - Added outbound connection: %s %d-%d to %s", rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteCIDR))
|
||||
}
|
||||
}
|
||||
|
||||
// Find removed rules
|
||||
for key, rule := range currentMap {
|
||||
if _, exists := desiredMap[key]; !exists {
|
||||
changes = append(changes, fmt.Sprintf(" - Removed outbound connection: %s %d-%d to %s", rule.Protocol, rule.PortRangeMin, rule.PortRangeMax, rule.RemoteCIDR))
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// compareInstanceStates compares current and desired instance states and returns changes
|
||||
func (p *EdgeConnectPlanner) compareInstanceStates(current, desired *InstanceState) []string {
|
||||
var changes []string
|
||||
|
||||
if current.FlavorName != desired.FlavorName {
|
||||
changes = append(changes, fmt.Sprintf("Flavor changed: %s -> %s", current.FlavorName, desired.FlavorName))
|
||||
}
|
||||
|
||||
if current.CloudletName != desired.CloudletName {
|
||||
changes = append(changes, fmt.Sprintf("Cloudlet changed: %s -> %s", current.CloudletName, desired.CloudletName))
|
||||
}
|
||||
|
||||
if current.CloudletOrg != desired.CloudletOrg {
|
||||
changes = append(changes, fmt.Sprintf("Cloudlet org changed: %s -> %s", current.CloudletOrg, desired.CloudletOrg))
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// calculateManifestHash computes the SHA256 hash of a manifest file
|
||||
func (p *EdgeConnectPlanner) calculateManifestHash(manifestPath string) (string, error) {
|
||||
if manifestPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
file, err := os.Open(manifestPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open manifest file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return "", fmt.Errorf("failed to hash manifest file: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// calculatePlanMetadata computes metadata for the deployment plan
|
||||
func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeploymentPlan) {
|
||||
totalActions := 0
|
||||
|
||||
if plan.AppAction.Type != ActionNone {
|
||||
totalActions++
|
||||
}
|
||||
|
||||
for _, action := range plan.InstanceActions {
|
||||
if action.Type != ActionNone {
|
||||
totalActions++
|
||||
}
|
||||
}
|
||||
|
||||
plan.TotalActions = totalActions
|
||||
|
||||
// Estimate duration based on action types and counts
|
||||
plan.EstimatedDuration = p.estimateDeploymentDuration(plan)
|
||||
}
|
||||
|
||||
// estimateDeploymentDuration provides a rough estimate of deployment time
|
||||
func (p *EdgeConnectPlanner) estimateDeploymentDuration(plan *DeploymentPlan) time.Duration {
|
||||
var duration time.Duration
|
||||
|
||||
// App operations
|
||||
switch plan.AppAction.Type {
|
||||
case ActionCreate:
|
||||
duration += 30 * time.Second
|
||||
case ActionUpdate:
|
||||
duration += 15 * time.Second
|
||||
}
|
||||
|
||||
// Instance operations (can be done in parallel)
|
||||
instanceDuration := time.Duration(0)
|
||||
for _, action := range plan.InstanceActions {
|
||||
switch action.Type {
|
||||
case ActionCreate:
|
||||
instanceDuration = max(instanceDuration, 2*time.Minute)
|
||||
case ActionUpdate:
|
||||
instanceDuration = max(instanceDuration, 1*time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
duration += instanceDuration
|
||||
|
||||
// Add buffer time
|
||||
duration += 30 * time.Second
|
||||
|
||||
return duration
|
||||
}
|
||||
|
||||
// isResourceNotFoundError checks if an error indicates a resource was not found
|
||||
func isResourceNotFoundError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
return strings.Contains(errStr, "not found") ||
|
||||
strings.Contains(errStr, "does not exist") ||
|
||||
strings.Contains(errStr, "404")
|
||||
}
|
||||
|
||||
// max returns the larger of two durations
|
||||
func max(a, b time.Duration) time.Duration {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// getInstanceName generates the instance name following the pattern: appName-appVersion-instance
|
||||
func getInstanceName(appName, appVersion string) string {
|
||||
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
|
||||
}
|
||||
663
internal/apply/v2/planner_test.go
Normal file
663
internal/apply/v2/planner_test.go
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
// ABOUTME: Comprehensive tests for EdgeConnect deployment planner with mock scenarios
|
||||
// ABOUTME: Tests planning logic, state comparison, and various deployment scenarios
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockEdgeConnectClient is a mock implementation of the EdgeConnect client
|
||||
type MockEdgeConnectClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return v2.App{}, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(v2.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) (v2.AppInstance, error) {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return v2.AppInstance{}, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(v2.AppInstance), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) CreateApp(ctx context.Context, input *v2.NewAppInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) CreateAppInstance(ctx context.Context, input *v2.NewAppInstanceInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) UpdateApp(ctx context.Context, input *v2.UpdateAppInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) UpdateAppInstance(ctx context.Context, input *v2.UpdateAppInstanceInput) error {
|
||||
args := m.Called(ctx, input)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowApps(ctx context.Context, appKey v2.AppKey, region string) ([]v2.App, error) {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]v2.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, region string) ([]v2.AppInstance, error) {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]v2.AppInstance), args.Error(1)
|
||||
}
|
||||
|
||||
func TestNewPlanner(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
|
||||
assert.NotNil(t, planner)
|
||||
assert.IsType(t, &EdgeConnectPlanner{}, planner)
|
||||
}
|
||||
|
||||
func TestDefaultPlanOptions(t *testing.T) {
|
||||
opts := DefaultPlanOptions()
|
||||
|
||||
assert.False(t, opts.DryRun)
|
||||
assert.False(t, opts.Force)
|
||||
assert.False(t, opts.SkipStateCheck)
|
||||
assert.True(t, opts.ParallelQueries)
|
||||
assert.Equal(t, 30*time.Second, opts.Timeout)
|
||||
}
|
||||
|
||||
func createTestConfig(t *testing.T) *config.EdgeConnectConfig {
|
||||
// Create temporary manifest file
|
||||
tempDir := t.TempDir()
|
||||
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
|
||||
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
|
||||
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &config.EdgeConnectConfig{
|
||||
Kind: "edgeconnect-deployment",
|
||||
Metadata: config.Metadata{
|
||||
Name: "test-app",
|
||||
AppVersion: "1.0.0",
|
||||
Organization: "testorg",
|
||||
},
|
||||
Spec: config.Spec{
|
||||
K8sApp: &config.K8sApp{
|
||||
ManifestFile: manifestFile,
|
||||
},
|
||||
InfraTemplate: []config.InfraTemplate{
|
||||
{
|
||||
Region: "US",
|
||||
CloudletOrg: "TestCloudletOrg",
|
||||
CloudletName: "TestCloudlet",
|
||||
FlavorName: "small",
|
||||
},
|
||||
},
|
||||
Network: &config.NetworkConfig{
|
||||
OutboundConnections: []config.OutboundConnection{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
PortRangeMax: 80,
|
||||
RemoteCIDR: "0.0.0.0/0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNewDeployment(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
testConfig := createTestConfig(t)
|
||||
|
||||
// Mock API calls to return "not found" errors
|
||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
||||
|
||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := planner.Plan(ctx, testConfig)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Plan)
|
||||
require.NoError(t, result.Error)
|
||||
|
||||
plan := result.Plan
|
||||
assert.Equal(t, "test-app", plan.ConfigName)
|
||||
assert.Equal(t, ActionCreate, plan.AppAction.Type)
|
||||
assert.Equal(t, "Application does not exist", plan.AppAction.Reason)
|
||||
|
||||
require.Len(t, plan.InstanceActions, 1)
|
||||
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
|
||||
assert.Equal(t, "Instance does not exist", plan.InstanceActions[0].Reason)
|
||||
|
||||
assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance
|
||||
assert.False(t, plan.IsEmpty())
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestPlanExistingDeploymentNoChanges(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
testConfig := createTestConfig(t)
|
||||
|
||||
// Note: We would calculate expected manifest hash here when API supports it
|
||||
|
||||
// Mock existing app with same manifest hash and outbound connections
|
||||
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
|
||||
existingApp := &v2.App{
|
||||
Key: v2.AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Deployment: "kubernetes",
|
||||
DeploymentManifest: manifestContent,
|
||||
RequiredOutboundConnections: []v2.SecurityRule{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
PortRangeMax: 80,
|
||||
RemoteCIDR: "0.0.0.0/0",
|
||||
},
|
||||
},
|
||||
// Note: Manifest hash tracking would be implemented when API supports annotations
|
||||
}
|
||||
|
||||
// Mock existing instance
|
||||
existingInstance := &v2.AppInstance{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app-1.0.0-instance",
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: "TestCloudletOrg",
|
||||
Name: "TestCloudlet",
|
||||
},
|
||||
},
|
||||
AppKey: v2.AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Flavor: v2.Flavor{
|
||||
Name: "small",
|
||||
},
|
||||
State: "Ready",
|
||||
PowerState: "PowerOn",
|
||||
}
|
||||
|
||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(*existingApp, nil)
|
||||
|
||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(*existingInstance, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := planner.Plan(ctx, testConfig)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Plan)
|
||||
|
||||
plan := result.Plan
|
||||
assert.Equal(t, ActionNone, plan.AppAction.Type)
|
||||
assert.Len(t, plan.InstanceActions, 1)
|
||||
assert.Equal(t, ActionNone, plan.InstanceActions[0].Type)
|
||||
assert.Equal(t, 0, plan.TotalActions)
|
||||
assert.True(t, plan.IsEmpty())
|
||||
assert.Contains(t, plan.Summary, "No changes required")
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestPlanWithOptions(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
testConfig := createTestConfig(t)
|
||||
|
||||
opts := PlanOptions{
|
||||
DryRun: true,
|
||||
SkipStateCheck: true,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := planner.PlanWithOptions(ctx, testConfig, opts)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Plan)
|
||||
|
||||
plan := result.Plan
|
||||
assert.True(t, plan.DryRun)
|
||||
assert.Equal(t, ActionCreate, plan.AppAction.Type)
|
||||
assert.Contains(t, plan.AppAction.Reason, "state check skipped")
|
||||
|
||||
// No API calls should be made when SkipStateCheck is true
|
||||
mockClient.AssertNotCalled(t, "ShowApp")
|
||||
mockClient.AssertNotCalled(t, "ShowAppInstance")
|
||||
}
|
||||
|
||||
func TestPlanMultipleInfrastructures(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
testConfig := createTestConfig(t)
|
||||
|
||||
// Add a second infrastructure target
|
||||
testConfig.Spec.InfraTemplate = append(testConfig.Spec.InfraTemplate, config.InfraTemplate{
|
||||
Region: "EU",
|
||||
CloudletOrg: "EUCloudletOrg",
|
||||
CloudletName: "EUCloudlet",
|
||||
FlavorName: "medium",
|
||||
})
|
||||
|
||||
// Mock API calls to return "not found" errors
|
||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
||||
|
||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
|
||||
|
||||
mockClient.On("ShowAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "EU").
|
||||
Return(nil, &v2.APIError{StatusCode: 404, Messages: []string{"Instance not found"}})
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := planner.Plan(ctx, testConfig)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Plan)
|
||||
|
||||
plan := result.Plan
|
||||
assert.Equal(t, ActionCreate, plan.AppAction.Type)
|
||||
|
||||
// Should have 2 instance actions, one for each infrastructure
|
||||
require.Len(t, plan.InstanceActions, 2)
|
||||
assert.Equal(t, ActionCreate, plan.InstanceActions[0].Type)
|
||||
assert.Equal(t, ActionCreate, plan.InstanceActions[1].Type)
|
||||
|
||||
assert.Equal(t, 3, plan.TotalActions) // 1 app + 2 instances
|
||||
|
||||
// Test cloudlet and region aggregation
|
||||
cloudlets := plan.GetTargetCloudlets()
|
||||
regions := plan.GetTargetRegions()
|
||||
assert.Len(t, cloudlets, 2)
|
||||
assert.Len(t, regions, 2)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestCalculateManifestHash(t *testing.T) {
|
||||
planner := &EdgeConnectPlanner{}
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create test file
|
||||
testFile := filepath.Join(tempDir, "test.yaml")
|
||||
content := "test content for hashing"
|
||||
err := os.WriteFile(testFile, []byte(content), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
hash1, err := planner.calculateManifestHash(testFile)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, hash1)
|
||||
assert.Len(t, hash1, 64) // SHA256 hex string length
|
||||
|
||||
// Same content should produce same hash
|
||||
hash2, err := planner.calculateManifestHash(testFile)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, hash1, hash2)
|
||||
|
||||
// Different content should produce different hash
|
||||
err = os.WriteFile(testFile, []byte("different content"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
hash3, err := planner.calculateManifestHash(testFile)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, hash1, hash3)
|
||||
|
||||
// Empty file path should return empty hash
|
||||
hash4, err := planner.calculateManifestHash("")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, hash4)
|
||||
|
||||
// Non-existent file should return error
|
||||
_, err = planner.calculateManifestHash("/non/existent/file")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCompareAppStates(t *testing.T) {
|
||||
planner := &EdgeConnectPlanner{}
|
||||
|
||||
current := &AppState{
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
AppType: AppTypeK8s,
|
||||
ManifestHash: "old-hash",
|
||||
}
|
||||
|
||||
desired := &AppState{
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
AppType: AppTypeK8s,
|
||||
ManifestHash: "new-hash",
|
||||
}
|
||||
|
||||
changes, manifestChanged := planner.compareAppStates(current, desired)
|
||||
assert.Len(t, changes, 1)
|
||||
assert.True(t, manifestChanged)
|
||||
assert.Contains(t, changes[0], "Manifest hash changed")
|
||||
|
||||
// Test no changes
|
||||
desired.ManifestHash = "old-hash"
|
||||
changes, manifestChanged = planner.compareAppStates(current, desired)
|
||||
assert.Empty(t, changes)
|
||||
assert.False(t, manifestChanged)
|
||||
|
||||
// Test app type change
|
||||
desired.AppType = AppTypeDocker
|
||||
changes, manifestChanged = planner.compareAppStates(current, desired)
|
||||
assert.Len(t, changes, 1)
|
||||
assert.False(t, manifestChanged)
|
||||
assert.Contains(t, changes[0], "App type changed")
|
||||
}
|
||||
|
||||
func TestCompareAppStatesOutboundConnections(t *testing.T) {
|
||||
planner := &EdgeConnectPlanner{}
|
||||
|
||||
// Test with no outbound connections
|
||||
current := &AppState{
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
AppType: AppTypeK8s,
|
||||
OutboundConnections: nil,
|
||||
}
|
||||
|
||||
desired := &AppState{
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
AppType: AppTypeK8s,
|
||||
OutboundConnections: nil,
|
||||
}
|
||||
|
||||
changes, _ := planner.compareAppStates(current, desired)
|
||||
assert.Empty(t, changes, "No changes expected when both have no outbound connections")
|
||||
|
||||
// Test adding outbound connections
|
||||
desired.OutboundConnections = []SecurityRule{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
PortRangeMax: 80,
|
||||
RemoteCIDR: "0.0.0.0/0",
|
||||
},
|
||||
}
|
||||
|
||||
changes, _ = planner.compareAppStates(current, desired)
|
||||
assert.Len(t, changes, 1)
|
||||
assert.Contains(t, changes[0], "Outbound connections changed")
|
||||
|
||||
// Test identical outbound connections
|
||||
current.OutboundConnections = []SecurityRule{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
PortRangeMax: 80,
|
||||
RemoteCIDR: "0.0.0.0/0",
|
||||
},
|
||||
}
|
||||
|
||||
changes, _ = planner.compareAppStates(current, desired)
|
||||
assert.Empty(t, changes, "No changes expected when outbound connections are identical")
|
||||
|
||||
// Test different outbound connections (different port)
|
||||
desired.OutboundConnections[0].PortRangeMin = 443
|
||||
desired.OutboundConnections[0].PortRangeMax = 443
|
||||
|
||||
changes, _ = planner.compareAppStates(current, desired)
|
||||
assert.Len(t, changes, 1)
|
||||
assert.Contains(t, changes[0], "Outbound connections changed")
|
||||
|
||||
// Test same connections but different order (should be considered equal)
|
||||
current.OutboundConnections = []SecurityRule{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
PortRangeMax: 80,
|
||||
RemoteCIDR: "0.0.0.0/0",
|
||||
},
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 443,
|
||||
PortRangeMax: 443,
|
||||
RemoteCIDR: "0.0.0.0/0",
|
||||
},
|
||||
}
|
||||
|
||||
desired.OutboundConnections = []SecurityRule{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 443,
|
||||
PortRangeMax: 443,
|
||||
RemoteCIDR: "0.0.0.0/0",
|
||||
},
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
PortRangeMax: 80,
|
||||
RemoteCIDR: "0.0.0.0/0",
|
||||
},
|
||||
}
|
||||
|
||||
changes, _ = planner.compareAppStates(current, desired)
|
||||
assert.Empty(t, changes, "No changes expected when outbound connections are same but in different order")
|
||||
|
||||
// Test removing outbound connections
|
||||
desired.OutboundConnections = nil
|
||||
|
||||
changes, _ = planner.compareAppStates(current, desired)
|
||||
assert.Len(t, changes, 1)
|
||||
assert.Contains(t, changes[0], "Outbound connections changed")
|
||||
}
|
||||
|
||||
func TestCompareInstanceStates(t *testing.T) {
|
||||
planner := &EdgeConnectPlanner{}
|
||||
|
||||
current := &InstanceState{
|
||||
Name: "test-instance",
|
||||
FlavorName: "small",
|
||||
CloudletName: "oldcloudlet",
|
||||
CloudletOrg: "oldorg",
|
||||
}
|
||||
|
||||
desired := &InstanceState{
|
||||
Name: "test-instance",
|
||||
FlavorName: "medium",
|
||||
CloudletName: "newcloudlet",
|
||||
CloudletOrg: "neworg",
|
||||
}
|
||||
|
||||
changes := planner.compareInstanceStates(current, desired)
|
||||
assert.Len(t, changes, 3)
|
||||
assert.Contains(t, changes[0], "Flavor changed")
|
||||
assert.Contains(t, changes[1], "Cloudlet changed")
|
||||
assert.Contains(t, changes[2], "Cloudlet org changed")
|
||||
|
||||
// Test no changes
|
||||
desired.FlavorName = "small"
|
||||
desired.CloudletName = "oldcloudlet"
|
||||
desired.CloudletOrg = "oldorg"
|
||||
changes = planner.compareInstanceStates(current, desired)
|
||||
assert.Empty(t, changes)
|
||||
}
|
||||
|
||||
func TestDeploymentPlanMethods(t *testing.T) {
|
||||
plan := &DeploymentPlan{
|
||||
ConfigName: "test-plan",
|
||||
AppAction: AppAction{
|
||||
Type: ActionCreate,
|
||||
Desired: &AppState{Name: "test-app"},
|
||||
},
|
||||
InstanceActions: []InstanceAction{
|
||||
{
|
||||
Type: ActionCreate,
|
||||
Target: config.InfraTemplate{
|
||||
CloudletOrg: "org1",
|
||||
CloudletName: "cloudlet1",
|
||||
Region: "US",
|
||||
},
|
||||
InstanceName: "instance1",
|
||||
Desired: &InstanceState{Name: "instance1"},
|
||||
},
|
||||
{
|
||||
Type: ActionUpdate,
|
||||
Target: config.InfraTemplate{
|
||||
CloudletOrg: "org2",
|
||||
CloudletName: "cloudlet2",
|
||||
Region: "EU",
|
||||
},
|
||||
InstanceName: "instance2",
|
||||
Desired: &InstanceState{Name: "instance2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Test IsEmpty
|
||||
assert.False(t, plan.IsEmpty())
|
||||
|
||||
// Test GetTargetCloudlets
|
||||
cloudlets := plan.GetTargetCloudlets()
|
||||
assert.Len(t, cloudlets, 2)
|
||||
assert.Contains(t, cloudlets, "org1:cloudlet1")
|
||||
assert.Contains(t, cloudlets, "org2:cloudlet2")
|
||||
|
||||
// Test GetTargetRegions
|
||||
regions := plan.GetTargetRegions()
|
||||
assert.Len(t, regions, 2)
|
||||
assert.Contains(t, regions, "US")
|
||||
assert.Contains(t, regions, "EU")
|
||||
|
||||
// Test GenerateSummary
|
||||
summary := plan.GenerateSummary()
|
||||
assert.Contains(t, summary, "test-plan")
|
||||
assert.Contains(t, summary, "CREATE application")
|
||||
assert.Contains(t, summary, "CREATE 1 instance")
|
||||
assert.Contains(t, summary, "UPDATE 1 instance")
|
||||
|
||||
// Test Validate
|
||||
err := plan.Validate()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test validation failure
|
||||
plan.AppAction.Desired = nil
|
||||
err = plan.Validate()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "must have desired state")
|
||||
}
|
||||
|
||||
func TestEstimateDeploymentDuration(t *testing.T) {
|
||||
planner := &EdgeConnectPlanner{}
|
||||
|
||||
plan := &DeploymentPlan{
|
||||
AppAction: AppAction{Type: ActionCreate},
|
||||
InstanceActions: []InstanceAction{
|
||||
{Type: ActionCreate},
|
||||
{Type: ActionUpdate},
|
||||
},
|
||||
}
|
||||
|
||||
duration := planner.estimateDeploymentDuration(plan)
|
||||
assert.Greater(t, duration, time.Duration(0))
|
||||
assert.Less(t, duration, 10*time.Minute) // Reasonable upper bound
|
||||
|
||||
// Test with no actions
|
||||
emptyPlan := &DeploymentPlan{
|
||||
AppAction: AppAction{Type: ActionNone},
|
||||
InstanceActions: []InstanceAction{},
|
||||
}
|
||||
|
||||
emptyDuration := planner.estimateDeploymentDuration(emptyPlan)
|
||||
assert.Greater(t, emptyDuration, time.Duration(0))
|
||||
assert.Less(t, emptyDuration, duration) // Should be less than plan with actions
|
||||
}
|
||||
|
||||
func TestIsResourceNotFoundError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{"nil error", nil, false},
|
||||
{"not found error", &v2.APIError{StatusCode: 404, Messages: []string{"Resource not found"}}, true},
|
||||
{"does not exist error", &v2.APIError{Messages: []string{"App does not exist"}}, true},
|
||||
{"404 in message", &v2.APIError{Messages: []string{"HTTP 404 error"}}, true},
|
||||
{"other error", &v2.APIError{StatusCode: 500, Messages: []string{"Server error"}}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isResourceNotFoundError(tt.err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanErrorHandling(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
testConfig := createTestConfig(t)
|
||||
|
||||
// Mock API call to return a non-404 error
|
||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(nil, &v2.APIError{StatusCode: 500, Messages: []string{"Server error"}})
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := planner.Plan(ctx, testConfig)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.NotNil(t, result.Error)
|
||||
assert.Contains(t, err.Error(), "failed to query current app state")
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
106
internal/apply/v2/strategy.go
Normal file
106
internal/apply/v2/strategy.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// ABOUTME: Deployment strategy framework for EdgeConnect apply command
|
||||
// ABOUTME: Defines interfaces and types for different deployment strategies (recreate, blue-green, rolling)
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
)
|
||||
|
||||
// DeploymentStrategy represents the type of deployment strategy
|
||||
type DeploymentStrategy string
|
||||
|
||||
const (
|
||||
// StrategyRecreate deletes all instances, updates app, then creates new instances
|
||||
StrategyRecreate DeploymentStrategy = "recreate"
|
||||
|
||||
// StrategyBlueGreen creates new instances alongside old ones, then switches traffic (future)
|
||||
StrategyBlueGreen DeploymentStrategy = "blue-green"
|
||||
|
||||
// StrategyRolling updates instances one by one with health checks (future)
|
||||
StrategyRolling DeploymentStrategy = "rolling"
|
||||
)
|
||||
|
||||
// DeploymentStrategyExecutor defines the interface that all deployment strategies must implement
|
||||
type DeploymentStrategyExecutor interface {
|
||||
// Execute runs the deployment strategy
|
||||
Execute(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error)
|
||||
|
||||
// Validate checks if the strategy can be used for this deployment
|
||||
Validate(plan *DeploymentPlan) error
|
||||
|
||||
// EstimateDuration provides time estimate for this strategy
|
||||
EstimateDuration(plan *DeploymentPlan) time.Duration
|
||||
|
||||
// GetName returns the strategy name
|
||||
GetName() DeploymentStrategy
|
||||
}
|
||||
|
||||
// StrategyConfig holds configuration for deployment strategies
|
||||
type StrategyConfig struct {
|
||||
// MaxRetries is the number of times to retry failed operations
|
||||
MaxRetries int
|
||||
|
||||
// HealthCheckTimeout is the maximum time to wait for health checks
|
||||
HealthCheckTimeout time.Duration
|
||||
|
||||
// ParallelOperations enables parallel execution of operations
|
||||
ParallelOperations bool
|
||||
|
||||
// RetryDelay is the delay between retry attempts
|
||||
RetryDelay time.Duration
|
||||
}
|
||||
|
||||
// DefaultStrategyConfig returns sensible defaults for strategy configuration
|
||||
func DefaultStrategyConfig() StrategyConfig {
|
||||
return StrategyConfig{
|
||||
MaxRetries: 5, // Retry 5 times
|
||||
HealthCheckTimeout: 5 * time.Minute, // Max 5 mins health check
|
||||
ParallelOperations: true, // Parallel execution
|
||||
RetryDelay: 10 * time.Second, // 10s between retries
|
||||
}
|
||||
}
|
||||
|
||||
// StrategyFactory creates deployment strategy executors
|
||||
type StrategyFactory struct {
|
||||
config StrategyConfig
|
||||
client EdgeConnectClientInterface
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewStrategyFactory creates a new strategy factory
|
||||
func NewStrategyFactory(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *StrategyFactory {
|
||||
return &StrategyFactory{
|
||||
config: config,
|
||||
client: client,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateStrategy creates the appropriate strategy executor based on the deployment strategy
|
||||
func (f *StrategyFactory) CreateStrategy(strategy DeploymentStrategy) (DeploymentStrategyExecutor, error) {
|
||||
switch strategy {
|
||||
case StrategyRecreate:
|
||||
return NewRecreateStrategy(f.client, f.config, f.logger), nil
|
||||
case StrategyBlueGreen:
|
||||
// TODO: Implement blue-green strategy
|
||||
return nil, fmt.Errorf("blue-green strategy not yet implemented")
|
||||
case StrategyRolling:
|
||||
// TODO: Implement rolling strategy
|
||||
return nil, fmt.Errorf("rolling strategy not yet implemented")
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown deployment strategy: %s", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAvailableStrategies returns a list of all available strategies
|
||||
func (f *StrategyFactory) GetAvailableStrategies() []DeploymentStrategy {
|
||||
return []DeploymentStrategy{
|
||||
StrategyRecreate,
|
||||
// StrategyBlueGreen, // TODO: Enable when implemented
|
||||
// StrategyRolling, // TODO: Enable when implemented
|
||||
}
|
||||
}
|
||||
641
internal/apply/v2/strategy_recreate.go
Normal file
641
internal/apply/v2/strategy_recreate.go
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
// ABOUTME: Recreate deployment strategy implementation for EdgeConnect
|
||||
// ABOUTME: Handles delete-all, update-app, create-all deployment pattern with retries and parallel execution
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
// RecreateStrategy implements the recreate deployment strategy
|
||||
type RecreateStrategy struct {
|
||||
client EdgeConnectClientInterface
|
||||
config StrategyConfig
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewRecreateStrategy creates a new recreate strategy executor
|
||||
func NewRecreateStrategy(client EdgeConnectClientInterface, config StrategyConfig, logger Logger) *RecreateStrategy {
|
||||
return &RecreateStrategy{
|
||||
client: client,
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetName returns the strategy name
|
||||
func (r *RecreateStrategy) GetName() DeploymentStrategy {
|
||||
return StrategyRecreate
|
||||
}
|
||||
|
||||
// Validate checks if the recreate strategy can be used for this deployment
|
||||
func (r *RecreateStrategy) Validate(plan *DeploymentPlan) error {
|
||||
// Recreate strategy can be used for any deployment
|
||||
// No specific constraints for recreate
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimateDuration estimates the time needed for recreate deployment
|
||||
func (r *RecreateStrategy) EstimateDuration(plan *DeploymentPlan) time.Duration {
|
||||
var duration time.Duration
|
||||
|
||||
// Delete phase - estimate based on number of instances
|
||||
instanceCount := len(plan.InstanceActions)
|
||||
if instanceCount > 0 {
|
||||
deleteTime := time.Duration(instanceCount) * 30 * time.Second
|
||||
if r.config.ParallelOperations {
|
||||
deleteTime = 30 * time.Second // Parallel deletion
|
||||
}
|
||||
duration += deleteTime
|
||||
}
|
||||
|
||||
// App update phase
|
||||
if plan.AppAction.Type == ActionUpdate {
|
||||
duration += 30 * time.Second
|
||||
}
|
||||
|
||||
// Create phase - estimate based on number of instances
|
||||
if instanceCount > 0 {
|
||||
createTime := time.Duration(instanceCount) * 2 * time.Minute
|
||||
if r.config.ParallelOperations {
|
||||
createTime = 2 * time.Minute // Parallel creation
|
||||
}
|
||||
duration += createTime
|
||||
}
|
||||
|
||||
// Health check time
|
||||
duration += r.config.HealthCheckTimeout
|
||||
|
||||
// Add retry buffer (potential retries)
|
||||
retryBuffer := time.Duration(r.config.MaxRetries) * r.config.RetryDelay
|
||||
duration += retryBuffer
|
||||
|
||||
return duration
|
||||
}
|
||||
|
||||
// Execute runs the recreate deployment strategy
|
||||
func (r *RecreateStrategy) Execute(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string) (*ExecutionResult, error) {
|
||||
startTime := time.Now()
|
||||
r.logf("Starting recreate deployment strategy for: %s", plan.ConfigName)
|
||||
|
||||
result := &ExecutionResult{
|
||||
Plan: plan,
|
||||
CompletedActions: []ActionResult{},
|
||||
FailedActions: []ActionResult{},
|
||||
}
|
||||
|
||||
// Phase 1: Delete all existing instances
|
||||
if err := r.deleteInstancesPhase(ctx, plan, config, result); err != nil {
|
||||
result.Error = err
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Phase 2: Delete existing app (if updating)
|
||||
if err := r.deleteAppPhase(ctx, plan, config, result); err != nil {
|
||||
result.Error = err
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Phase 3: Create/recreate application
|
||||
if err := r.createAppPhase(ctx, plan, config, manifestContent, result); err != nil {
|
||||
result.Error = err
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Phase 4: Create new instances
|
||||
if err := r.createInstancesPhase(ctx, plan, config, result); err != nil {
|
||||
result.Error = err
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Phase 5: Health check (wait for instances to be ready)
|
||||
if err := r.healthCheckPhase(ctx, plan, result); err != nil {
|
||||
result.Error = err
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.Success = len(result.FailedActions) == 0
|
||||
result.Duration = time.Since(startTime)
|
||||
|
||||
if result.Success {
|
||||
r.logf("Recreate deployment completed successfully in %v", result.Duration)
|
||||
} else {
|
||||
r.logf("Recreate deployment failed with %d failed actions", len(result.FailedActions))
|
||||
}
|
||||
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
// deleteInstancesPhase deletes all existing instances
|
||||
func (r *RecreateStrategy) deleteInstancesPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
|
||||
r.logf("Phase 1: Deleting existing instances")
|
||||
|
||||
// Only delete instances that exist (have ActionUpdate or ActionNone type)
|
||||
instancesToDelete := []InstanceAction{}
|
||||
for _, action := range plan.InstanceActions {
|
||||
if action.Type == ActionUpdate || action.Type == ActionNone {
|
||||
// Convert to delete action
|
||||
deleteAction := action
|
||||
deleteAction.Type = ActionDelete
|
||||
deleteAction.Reason = "Recreate strategy: deleting for recreation"
|
||||
instancesToDelete = append(instancesToDelete, deleteAction)
|
||||
}
|
||||
}
|
||||
|
||||
if len(instancesToDelete) == 0 {
|
||||
r.logf("No existing instances to delete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Backup instances before deleting them (for rollback restoration)
|
||||
r.logf("Backing up %d existing instances before deletion", len(instancesToDelete))
|
||||
for _, action := range instancesToDelete {
|
||||
backup, err := r.backupInstance(ctx, action, config)
|
||||
if err != nil {
|
||||
r.logf("Warning: failed to backup instance %s before deletion: %v", action.InstanceName, err)
|
||||
// Continue with deletion even if backup fails - this is best effort
|
||||
} else {
|
||||
result.DeletedInstancesBackup = append(result.DeletedInstancesBackup, *backup)
|
||||
r.logf("Backed up instance: %s", action.InstanceName)
|
||||
}
|
||||
}
|
||||
|
||||
deleteResults := r.executeInstanceActionsWithRetry(ctx, instancesToDelete, "delete", config)
|
||||
|
||||
for _, deleteResult := range deleteResults {
|
||||
if deleteResult.Success {
|
||||
result.CompletedActions = append(result.CompletedActions, deleteResult)
|
||||
r.logf("Deleted instance: %s", deleteResult.Target)
|
||||
} else {
|
||||
result.FailedActions = append(result.FailedActions, deleteResult)
|
||||
return fmt.Errorf("failed to delete instance %s: %w", deleteResult.Target, deleteResult.Error)
|
||||
}
|
||||
}
|
||||
|
||||
r.logf("Phase 1 complete: deleted %d instances", len(deleteResults))
|
||||
|
||||
// Wait for Kubernetes namespace termination to complete
|
||||
// This prevents "namespace is being terminated" errors when recreating instances
|
||||
if len(deleteResults) > 0 {
|
||||
waitTime := 5 * time.Second
|
||||
r.logf("Waiting %v for namespace termination to complete...", waitTime)
|
||||
select {
|
||||
case <-time.After(waitTime):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteAppPhase deletes the existing app (if updating)
|
||||
func (r *RecreateStrategy) deleteAppPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
|
||||
if plan.AppAction.Type != ActionUpdate {
|
||||
r.logf("Phase 2: No app deletion needed (new app)")
|
||||
return nil
|
||||
}
|
||||
|
||||
r.logf("Phase 2: Deleting existing application")
|
||||
|
||||
// Backup app before deleting it (for rollback restoration)
|
||||
r.logf("Backing up existing app before deletion")
|
||||
backup, err := r.backupApp(ctx, plan, config)
|
||||
if err != nil {
|
||||
r.logf("Warning: failed to backup app before deletion: %v", err)
|
||||
// Continue with deletion even if backup fails - this is best effort
|
||||
} else {
|
||||
result.DeletedAppBackup = backup
|
||||
r.logf("Backed up app: %s", plan.AppAction.Desired.Name)
|
||||
}
|
||||
|
||||
appKey := v2.AppKey{
|
||||
Organization: plan.AppAction.Desired.Organization,
|
||||
Name: plan.AppAction.Desired.Name,
|
||||
Version: plan.AppAction.Desired.Version,
|
||||
}
|
||||
|
||||
if err := r.client.DeleteApp(ctx, appKey, plan.AppAction.Desired.Region); err != nil {
|
||||
result.FailedActions = append(result.FailedActions, ActionResult{
|
||||
Type: ActionDelete,
|
||||
Target: plan.AppAction.Desired.Name,
|
||||
Success: false,
|
||||
Error: err,
|
||||
})
|
||||
return fmt.Errorf("failed to delete app: %w", err)
|
||||
}
|
||||
|
||||
result.CompletedActions = append(result.CompletedActions, ActionResult{
|
||||
Type: ActionDelete,
|
||||
Target: plan.AppAction.Desired.Name,
|
||||
Success: true,
|
||||
Details: fmt.Sprintf("Deleted app %s", plan.AppAction.Desired.Name),
|
||||
})
|
||||
|
||||
r.logf("Phase 2 complete: deleted existing application")
|
||||
return nil
|
||||
}
|
||||
|
||||
// createAppPhase creates the application (always create since we deleted it first)
|
||||
func (r *RecreateStrategy) createAppPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, manifestContent string, result *ExecutionResult) error {
|
||||
if plan.AppAction.Type == ActionNone {
|
||||
r.logf("Phase 3: No app creation needed")
|
||||
return nil
|
||||
}
|
||||
|
||||
r.logf("Phase 3: Creating application")
|
||||
|
||||
// Always use create since recreate strategy deletes first
|
||||
createAction := plan.AppAction
|
||||
createAction.Type = ActionCreate
|
||||
createAction.Reason = "Recreate strategy: creating app"
|
||||
|
||||
appResult := r.executeAppActionWithRetry(ctx, createAction, config, manifestContent)
|
||||
|
||||
if appResult.Success {
|
||||
result.CompletedActions = append(result.CompletedActions, appResult)
|
||||
r.logf("Phase 3 complete: app created successfully")
|
||||
return nil
|
||||
} else {
|
||||
result.FailedActions = append(result.FailedActions, appResult)
|
||||
return fmt.Errorf("failed to create app: %w", appResult.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// createInstancesPhase creates new instances
|
||||
func (r *RecreateStrategy) createInstancesPhase(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig, result *ExecutionResult) error {
|
||||
r.logf("Phase 4: Creating new instances")
|
||||
|
||||
// Convert all instance actions to create
|
||||
instancesToCreate := []InstanceAction{}
|
||||
for _, action := range plan.InstanceActions {
|
||||
createAction := action
|
||||
createAction.Type = ActionCreate
|
||||
createAction.Reason = "Recreate strategy: creating new instance"
|
||||
instancesToCreate = append(instancesToCreate, createAction)
|
||||
}
|
||||
|
||||
if len(instancesToCreate) == 0 {
|
||||
r.logf("No instances to create")
|
||||
return nil
|
||||
}
|
||||
|
||||
createResults := r.executeInstanceActionsWithRetry(ctx, instancesToCreate, "create", config)
|
||||
|
||||
for _, createResult := range createResults {
|
||||
if createResult.Success {
|
||||
result.CompletedActions = append(result.CompletedActions, createResult)
|
||||
r.logf("Created instance: %s", createResult.Target)
|
||||
} else {
|
||||
result.FailedActions = append(result.FailedActions, createResult)
|
||||
return fmt.Errorf("failed to create instance %s: %w", createResult.Target, createResult.Error)
|
||||
}
|
||||
}
|
||||
|
||||
r.logf("Phase 4 complete: created %d instances", len(createResults))
|
||||
return nil
|
||||
}
|
||||
|
||||
// healthCheckPhase waits for instances to become ready
|
||||
func (r *RecreateStrategy) healthCheckPhase(ctx context.Context, plan *DeploymentPlan, result *ExecutionResult) error {
|
||||
if len(plan.InstanceActions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.logf("Phase 5: Performing health checks")
|
||||
|
||||
// TODO: Implement actual health checks by querying instance status
|
||||
// For now, skip waiting in tests/mock environments
|
||||
r.logf("Phase 5 complete: health check passed (no wait)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeInstanceActionsWithRetry executes instance actions with retry logic
|
||||
func (r *RecreateStrategy) executeInstanceActionsWithRetry(ctx context.Context, actions []InstanceAction, operation string, config *config.EdgeConnectConfig) []ActionResult {
|
||||
results := make([]ActionResult, len(actions))
|
||||
|
||||
if r.config.ParallelOperations && len(actions) > 1 {
|
||||
// Parallel execution
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 5) // Limit concurrency
|
||||
|
||||
for i, action := range actions {
|
||||
wg.Add(1)
|
||||
go func(index int, instanceAction InstanceAction) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
results[index] = r.executeInstanceActionWithRetry(ctx, instanceAction, operation, config)
|
||||
}(i, action)
|
||||
}
|
||||
wg.Wait()
|
||||
} else {
|
||||
// Sequential execution
|
||||
for i, action := range actions {
|
||||
results[i] = r.executeInstanceActionWithRetry(ctx, action, operation, config)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// executeInstanceActionWithRetry executes a single instance action with retry logic
|
||||
func (r *RecreateStrategy) executeInstanceActionWithRetry(ctx context.Context, action InstanceAction, operation string, config *config.EdgeConnectConfig) ActionResult {
|
||||
startTime := time.Now()
|
||||
result := ActionResult{
|
||||
Type: action.Type,
|
||||
Target: action.InstanceName,
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
r.logf("Retrying %s for instance %s (attempt %d/%d)", operation, action.InstanceName, attempt, r.config.MaxRetries)
|
||||
select {
|
||||
case <-time.After(r.config.RetryDelay):
|
||||
case <-ctx.Done():
|
||||
result.Error = ctx.Err()
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
var success bool
|
||||
var err error
|
||||
|
||||
switch action.Type {
|
||||
case ActionDelete:
|
||||
success, err = r.deleteInstance(ctx, action)
|
||||
case ActionCreate:
|
||||
success, err = r.createInstance(ctx, action, config)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported action type: %s", action.Type)
|
||||
}
|
||||
|
||||
if success {
|
||||
result.Success = true
|
||||
result.Details = fmt.Sprintf("Successfully %sd instance %s", strings.ToLower(string(action.Type)), action.InstanceName)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Check if error is retryable (don't retry 4xx client errors)
|
||||
if !isRetryableError(err) {
|
||||
r.logf("Failed to %s instance %s: %v (non-retryable error, giving up)", operation, action.InstanceName, err)
|
||||
result.Error = fmt.Errorf("non-retryable error: %w", err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
if attempt < r.config.MaxRetries {
|
||||
r.logf("Failed to %s instance %s: %v (will retry)", operation, action.InstanceName, err)
|
||||
}
|
||||
}
|
||||
|
||||
result.Error = fmt.Errorf("failed after %d attempts: %w", r.config.MaxRetries+1, lastErr)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
// executeAppActionWithRetry executes app action with retry logic
|
||||
func (r *RecreateStrategy) executeAppActionWithRetry(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) ActionResult {
|
||||
startTime := time.Now()
|
||||
result := ActionResult{
|
||||
Type: action.Type,
|
||||
Target: action.Desired.Name,
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= r.config.MaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
r.logf("Retrying app update (attempt %d/%d)", attempt, r.config.MaxRetries)
|
||||
select {
|
||||
case <-time.After(r.config.RetryDelay):
|
||||
case <-ctx.Done():
|
||||
result.Error = ctx.Err()
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
success, err := r.updateApplication(ctx, action, config, manifestContent)
|
||||
if success {
|
||||
result.Success = true
|
||||
result.Details = fmt.Sprintf("Successfully updated application %s", action.Desired.Name)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Check if error is retryable (don't retry 4xx client errors)
|
||||
if !isRetryableError(err) {
|
||||
r.logf("Failed to update app: %v (non-retryable error, giving up)", err)
|
||||
result.Error = fmt.Errorf("non-retryable error: %w", err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
if attempt < r.config.MaxRetries {
|
||||
r.logf("Failed to update app: %v (will retry)", err)
|
||||
}
|
||||
}
|
||||
|
||||
result.Error = fmt.Errorf("failed after %d attempts: %w", r.config.MaxRetries+1, lastErr)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
// deleteInstance deletes an instance (reuse existing logic from manager.go)
|
||||
func (r *RecreateStrategy) deleteInstance(ctx context.Context, action InstanceAction) (bool, error) {
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.InstanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: action.Target.CloudletOrg,
|
||||
Name: action.Target.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
err := r.client.DeleteAppInstance(ctx, instanceKey, action.Target.Region)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to delete instance: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// createInstance creates an instance (extracted from manager.go logic)
|
||||
func (r *RecreateStrategy) createInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (bool, error) {
|
||||
instanceInput := &v2.NewAppInstanceInput{
|
||||
Region: action.Target.Region,
|
||||
AppInst: v2.AppInstance{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.InstanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: action.Target.CloudletOrg,
|
||||
Name: action.Target.CloudletName,
|
||||
},
|
||||
},
|
||||
AppKey: v2.AppKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Metadata.AppVersion,
|
||||
},
|
||||
Flavor: v2.Flavor{
|
||||
Name: action.Target.FlavorName,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create the instance
|
||||
if err := r.client.CreateAppInstance(ctx, instanceInput); err != nil {
|
||||
return false, fmt.Errorf("failed to create instance: %w", err)
|
||||
}
|
||||
|
||||
r.logf("Successfully created instance: %s on %s:%s",
|
||||
action.InstanceName, action.Target.CloudletOrg, action.Target.CloudletName)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// updateApplication creates/recreates an application (always uses CreateApp since we delete first)
|
||||
func (r *RecreateStrategy) updateApplication(ctx context.Context, action AppAction, config *config.EdgeConnectConfig, manifestContent string) (bool, error) {
|
||||
// Build the app create input - always create since recreate strategy deletes first
|
||||
appInput := &v2.NewAppInput{
|
||||
Region: action.Desired.Region,
|
||||
App: v2.App{
|
||||
Key: v2.AppKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.Desired.Name,
|
||||
Version: action.Desired.Version,
|
||||
},
|
||||
Deployment: config.GetDeploymentType(),
|
||||
ImageType: "ImageTypeDocker",
|
||||
ImagePath: config.GetImagePath(),
|
||||
AllowServerless: true,
|
||||
DefaultFlavor: v2.Flavor{Name: config.Spec.InfraTemplate[0].FlavorName},
|
||||
ServerlessConfig: struct{}{},
|
||||
DeploymentManifest: manifestContent,
|
||||
DeploymentGenerator: "kubernetes-basic",
|
||||
},
|
||||
}
|
||||
|
||||
// Add network configuration if specified
|
||||
if config.Spec.Network != nil {
|
||||
appInput.App.RequiredOutboundConnections = convertNetworkRules(config.Spec.Network)
|
||||
}
|
||||
|
||||
// Create the application (recreate strategy always creates from scratch)
|
||||
if err := r.client.CreateApp(ctx, appInput); err != nil {
|
||||
return false, fmt.Errorf("failed to create application: %w", err)
|
||||
}
|
||||
|
||||
r.logf("Successfully created application: %s/%s version %s",
|
||||
action.Desired.Organization, action.Desired.Name, action.Desired.Version)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// backupApp fetches and stores the current app state before deletion
|
||||
func (r *RecreateStrategy) backupApp(ctx context.Context, plan *DeploymentPlan, config *config.EdgeConnectConfig) (*AppBackup, error) {
|
||||
appKey := v2.AppKey{
|
||||
Organization: plan.AppAction.Desired.Organization,
|
||||
Name: plan.AppAction.Desired.Name,
|
||||
Version: plan.AppAction.Desired.Version,
|
||||
}
|
||||
|
||||
app, err := r.client.ShowApp(ctx, appKey, plan.AppAction.Desired.Region)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch app for backup: %w", err)
|
||||
}
|
||||
|
||||
backup := &AppBackup{
|
||||
App: app,
|
||||
Region: plan.AppAction.Desired.Region,
|
||||
ManifestContent: app.DeploymentManifest,
|
||||
}
|
||||
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
// backupInstance fetches and stores the current instance state before deletion
|
||||
func (r *RecreateStrategy) backupInstance(ctx context.Context, action InstanceAction, config *config.EdgeConnectConfig) (*InstanceBackup, error) {
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: action.Desired.Organization,
|
||||
Name: action.InstanceName,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: action.Target.CloudletOrg,
|
||||
Name: action.Target.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
appKey := v2.AppKey{Name: action.Desired.AppName}
|
||||
|
||||
instance, err := r.client.ShowAppInstance(ctx, instanceKey, appKey, action.Target.Region)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch instance for backup: %w", err)
|
||||
}
|
||||
|
||||
backup := &InstanceBackup{
|
||||
Instance: instance,
|
||||
Region: action.Target.Region,
|
||||
}
|
||||
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
// logf logs a message if a logger is configured
|
||||
func (r *RecreateStrategy) logf(format string, v ...interface{}) {
|
||||
if r.logger != nil {
|
||||
r.logger.Printf("[RecreateStrategy] "+format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
// isRetryableError determines if an error should be retried
|
||||
// Returns false for client errors (4xx), true for server errors (5xx) and other transient errors
|
||||
func isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Special case: Kubernetes namespace termination race condition
|
||||
// This is a transient 400 error that should be retried
|
||||
if strings.Contains(errStr, "being terminated") || strings.Contains(errStr, "is being terminated") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if it's an APIError with a status code
|
||||
var apiErr *v2.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
// Don't retry client errors (4xx)
|
||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
|
||||
return false
|
||||
}
|
||||
// Retry server errors (5xx)
|
||||
if apiErr.StatusCode >= 500 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Retry all other errors (network issues, timeouts, etc.)
|
||||
return true
|
||||
}
|
||||
489
internal/apply/v2/types.go
Normal file
489
internal/apply/v2/types.go
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
// ABOUTME: Deployment planning types for EdgeConnect apply command with state management
|
||||
// ABOUTME: Defines structures for deployment plans, actions, and state comparison results
|
||||
package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
// SecurityRule defines network access rules (alias to SDK type for consistency)
|
||||
type SecurityRule = v2.SecurityRule
|
||||
|
||||
// ActionType represents the type of action to be performed
|
||||
type ActionType string
|
||||
|
||||
const (
|
||||
// ActionCreate indicates a resource needs to be created
|
||||
ActionCreate ActionType = "CREATE"
|
||||
// ActionUpdate indicates a resource needs to be updated
|
||||
ActionUpdate ActionType = "UPDATE"
|
||||
// ActionNone indicates no action is needed
|
||||
ActionNone ActionType = "NONE"
|
||||
// ActionDelete indicates a resource needs to be deleted (for rollback scenarios)
|
||||
ActionDelete ActionType = "DELETE"
|
||||
)
|
||||
|
||||
// String returns the string representation of ActionType
|
||||
func (a ActionType) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
// DeploymentPlan represents the complete deployment plan for a configuration
|
||||
type DeploymentPlan struct {
|
||||
// ConfigName is the name from metadata
|
||||
ConfigName string
|
||||
|
||||
// AppAction defines what needs to be done with the application
|
||||
AppAction AppAction
|
||||
|
||||
// InstanceActions defines what needs to be done with each instance
|
||||
InstanceActions []InstanceAction
|
||||
|
||||
// Summary provides a human-readable summary of the plan
|
||||
Summary string
|
||||
|
||||
// TotalActions is the count of all actions that will be performed
|
||||
TotalActions int
|
||||
|
||||
// EstimatedDuration is the estimated time to complete the deployment
|
||||
EstimatedDuration time.Duration
|
||||
|
||||
// CreatedAt timestamp when the plan was created
|
||||
CreatedAt time.Time
|
||||
|
||||
// DryRun indicates if this is a dry-run plan
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// AppAction represents an action to be performed on an application
|
||||
type AppAction struct {
|
||||
// Type of action to perform
|
||||
Type ActionType
|
||||
|
||||
// Current state of the app (nil if doesn't exist)
|
||||
Current *AppState
|
||||
|
||||
// Desired state of the app
|
||||
Desired *AppState
|
||||
|
||||
// Changes describes what will change
|
||||
Changes []string
|
||||
|
||||
// Reason explains why this action is needed
|
||||
Reason string
|
||||
|
||||
// ManifestHash is the hash of the current manifest file
|
||||
ManifestHash string
|
||||
|
||||
// ManifestChanged indicates if the manifest content has changed
|
||||
ManifestChanged bool
|
||||
}
|
||||
|
||||
// InstanceAction represents an action to be performed on an application instance
|
||||
type InstanceAction struct {
|
||||
// Type of action to perform
|
||||
Type ActionType
|
||||
|
||||
// Target infrastructure where the instance will be deployed
|
||||
Target config.InfraTemplate
|
||||
|
||||
// Current state of the instance (nil if doesn't exist)
|
||||
Current *InstanceState
|
||||
|
||||
// Desired state of the instance
|
||||
Desired *InstanceState
|
||||
|
||||
// Changes describes what will change
|
||||
Changes []string
|
||||
|
||||
// Reason explains why this action is needed
|
||||
Reason string
|
||||
|
||||
// InstanceName is the generated name for this instance
|
||||
InstanceName string
|
||||
|
||||
// Dependencies lists other instances this depends on
|
||||
Dependencies []string
|
||||
}
|
||||
|
||||
// AppState represents the current state of an application
|
||||
type AppState struct {
|
||||
// Name of the application
|
||||
Name string
|
||||
|
||||
// Version of the application
|
||||
Version string
|
||||
|
||||
// Organization that owns the app
|
||||
Organization string
|
||||
|
||||
// Region where the app is deployed
|
||||
Region string
|
||||
|
||||
// ManifestHash is the stored hash of the manifest file
|
||||
ManifestHash string
|
||||
|
||||
// LastUpdated timestamp when the app was last modified
|
||||
LastUpdated time.Time
|
||||
|
||||
// Exists indicates if the app currently exists
|
||||
Exists bool
|
||||
|
||||
// AppType indicates whether this is a k8s or docker app
|
||||
AppType AppType
|
||||
|
||||
// OutboundConnections contains the required outbound network connections
|
||||
OutboundConnections []SecurityRule
|
||||
}
|
||||
|
||||
// InstanceState represents the current state of an application instance
|
||||
type InstanceState struct {
|
||||
// Name of the instance
|
||||
Name string
|
||||
|
||||
// AppName that this instance belongs to
|
||||
AppName string
|
||||
|
||||
// AppVersion of the associated app
|
||||
AppVersion string
|
||||
|
||||
// Organization that owns the instance
|
||||
Organization string
|
||||
|
||||
// Region where the instance is deployed
|
||||
Region string
|
||||
|
||||
// CloudletOrg that hosts the cloudlet
|
||||
CloudletOrg string
|
||||
|
||||
// CloudletName where the instance is running
|
||||
CloudletName string
|
||||
|
||||
// FlavorName used for the instance
|
||||
FlavorName string
|
||||
|
||||
// State of the instance (e.g., "Ready", "Pending", "Error")
|
||||
State string
|
||||
|
||||
// PowerState of the instance
|
||||
PowerState string
|
||||
|
||||
// LastUpdated timestamp when the instance was last modified
|
||||
LastUpdated time.Time
|
||||
|
||||
// Exists indicates if the instance currently exists
|
||||
Exists bool
|
||||
}
|
||||
|
||||
// AppType represents the type of application
|
||||
type AppType string
|
||||
|
||||
const (
|
||||
// AppTypeK8s represents a Kubernetes application
|
||||
AppTypeK8s AppType = "k8s"
|
||||
// AppTypeDocker represents a Docker application
|
||||
AppTypeDocker AppType = "docker"
|
||||
)
|
||||
|
||||
// String returns the string representation of AppType
|
||||
func (a AppType) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
// DeploymentSummary provides a high-level overview of the deployment plan
|
||||
type DeploymentSummary struct {
|
||||
// TotalActions is the total number of actions to be performed
|
||||
TotalActions int
|
||||
|
||||
// ActionCounts breaks down actions by type
|
||||
ActionCounts map[ActionType]int
|
||||
|
||||
// EstimatedDuration for the entire deployment
|
||||
EstimatedDuration time.Duration
|
||||
|
||||
// ResourceSummary describes the resources involved
|
||||
ResourceSummary ResourceSummary
|
||||
|
||||
// Warnings about potential issues
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// ResourceSummary provides details about resources in the deployment
|
||||
type ResourceSummary struct {
|
||||
// AppsToCreate number of apps that will be created
|
||||
AppsToCreate int
|
||||
|
||||
// AppsToUpdate number of apps that will be updated
|
||||
AppsToUpdate int
|
||||
|
||||
// InstancesToCreate number of instances that will be created
|
||||
InstancesToCreate int
|
||||
|
||||
// InstancesToUpdate number of instances that will be updated
|
||||
InstancesToUpdate int
|
||||
|
||||
// CloudletsAffected number of unique cloudlets involved
|
||||
CloudletsAffected int
|
||||
|
||||
// RegionsAffected number of unique regions involved
|
||||
RegionsAffected int
|
||||
}
|
||||
|
||||
// PlanResult represents the result of a deployment planning operation
|
||||
type PlanResult struct {
|
||||
// Plan is the generated deployment plan
|
||||
Plan *DeploymentPlan
|
||||
|
||||
// Error if planning failed
|
||||
Error error
|
||||
|
||||
// Warnings encountered during planning
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// ExecutionResult represents the result of executing a deployment plan
|
||||
type ExecutionResult struct {
|
||||
// Plan that was executed
|
||||
Plan *DeploymentPlan
|
||||
|
||||
// Success indicates if the deployment was successful
|
||||
Success bool
|
||||
|
||||
// CompletedActions lists actions that were successfully completed
|
||||
CompletedActions []ActionResult
|
||||
|
||||
// FailedActions lists actions that failed
|
||||
FailedActions []ActionResult
|
||||
|
||||
// Error that caused the deployment to fail (if any)
|
||||
Error error
|
||||
|
||||
// Duration taken to execute the plan
|
||||
Duration time.Duration
|
||||
|
||||
// RollbackPerformed indicates if rollback was executed
|
||||
RollbackPerformed bool
|
||||
|
||||
// RollbackSuccess indicates if rollback was successful
|
||||
RollbackSuccess bool
|
||||
|
||||
// DeletedAppBackup stores the app that was deleted (for rollback restoration)
|
||||
DeletedAppBackup *AppBackup
|
||||
|
||||
// DeletedInstancesBackup stores instances that were deleted (for rollback restoration)
|
||||
DeletedInstancesBackup []InstanceBackup
|
||||
}
|
||||
|
||||
// ActionResult represents the result of executing a single action
|
||||
type ActionResult struct {
|
||||
// Type of action that was attempted
|
||||
Type ActionType
|
||||
|
||||
// Target describes what was being acted upon
|
||||
Target string
|
||||
|
||||
// Success indicates if the action succeeded
|
||||
Success bool
|
||||
|
||||
// Error if the action failed
|
||||
Error error
|
||||
|
||||
// Duration taken to complete the action
|
||||
Duration time.Duration
|
||||
|
||||
// Details provides additional information about the action
|
||||
Details string
|
||||
}
|
||||
|
||||
// AppBackup stores a deleted app's complete state for rollback restoration
|
||||
type AppBackup struct {
|
||||
// App is the full app object that was deleted
|
||||
App v2.App
|
||||
|
||||
// Region where the app was deployed
|
||||
Region string
|
||||
|
||||
// ManifestContent is the deployment manifest content
|
||||
ManifestContent string
|
||||
}
|
||||
|
||||
// InstanceBackup stores a deleted instance's complete state for rollback restoration
|
||||
type InstanceBackup struct {
|
||||
// Instance is the full instance object that was deleted
|
||||
Instance v2.AppInstance
|
||||
|
||||
// Region where the instance was deployed
|
||||
Region string
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the deployment plan has no actions to perform
|
||||
func (dp *DeploymentPlan) IsEmpty() bool {
|
||||
if dp.AppAction.Type != ActionNone {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, action := range dp.InstanceActions {
|
||||
if action.Type != ActionNone {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// HasErrors returns true if the plan contains any error conditions
|
||||
func (dp *DeploymentPlan) HasErrors() bool {
|
||||
// Check for conflicting actions or invalid states
|
||||
return false // Implementation would check for various error conditions
|
||||
}
|
||||
|
||||
// GetTargetCloudlets returns a list of unique cloudlets that will be affected
|
||||
func (dp *DeploymentPlan) GetTargetCloudlets() []string {
|
||||
cloudletSet := make(map[string]bool)
|
||||
var cloudlets []string
|
||||
|
||||
for _, action := range dp.InstanceActions {
|
||||
if action.Type != ActionNone {
|
||||
key := fmt.Sprintf("%s:%s", action.Target.CloudletOrg, action.Target.CloudletName)
|
||||
if !cloudletSet[key] {
|
||||
cloudletSet[key] = true
|
||||
cloudlets = append(cloudlets, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cloudlets
|
||||
}
|
||||
|
||||
// GetTargetRegions returns a list of unique regions that will be affected
|
||||
func (dp *DeploymentPlan) GetTargetRegions() []string {
|
||||
regionSet := make(map[string]bool)
|
||||
var regions []string
|
||||
|
||||
for _, action := range dp.InstanceActions {
|
||||
if action.Type != ActionNone && !regionSet[action.Target.Region] {
|
||||
regionSet[action.Target.Region] = true
|
||||
regions = append(regions, action.Target.Region)
|
||||
}
|
||||
}
|
||||
|
||||
return regions
|
||||
}
|
||||
|
||||
// GenerateSummary creates a human-readable summary of the deployment plan
|
||||
func (dp *DeploymentPlan) GenerateSummary() string {
|
||||
if dp.IsEmpty() {
|
||||
return "No changes required - configuration matches current state"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Deployment plan for '%s':\n", dp.ConfigName))
|
||||
|
||||
// App actions
|
||||
if dp.AppAction.Type != ActionNone {
|
||||
sb.WriteString(fmt.Sprintf("- %s application '%s'\n", dp.AppAction.Type, dp.AppAction.Desired.Name))
|
||||
if len(dp.AppAction.Changes) > 0 {
|
||||
for _, change := range dp.AppAction.Changes {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", change))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instance actions
|
||||
createCount := 0
|
||||
updateActions := []InstanceAction{}
|
||||
for _, action := range dp.InstanceActions {
|
||||
switch action.Type {
|
||||
case ActionCreate:
|
||||
createCount++
|
||||
case ActionUpdate:
|
||||
updateActions = append(updateActions, action)
|
||||
}
|
||||
}
|
||||
|
||||
if createCount > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- CREATE %d instance(s) across %d cloudlet(s)\n", createCount, len(dp.GetTargetCloudlets())))
|
||||
}
|
||||
|
||||
if len(updateActions) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- UPDATE %d instance(s)\n", len(updateActions)))
|
||||
for _, action := range updateActions {
|
||||
if len(action.Changes) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" - Instance '%s':\n", action.InstanceName))
|
||||
for _, change := range action.Changes {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", change))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Validate checks if the deployment plan is valid and safe to execute
|
||||
func (dp *DeploymentPlan) Validate() error {
|
||||
if dp.ConfigName == "" {
|
||||
return fmt.Errorf("deployment plan must have a config name")
|
||||
}
|
||||
|
||||
// Validate app action
|
||||
if dp.AppAction.Type != ActionNone && dp.AppAction.Desired == nil {
|
||||
return fmt.Errorf("app action of type %s must have desired state", dp.AppAction.Type)
|
||||
}
|
||||
|
||||
// Validate instance actions
|
||||
for i, action := range dp.InstanceActions {
|
||||
if action.Type != ActionNone {
|
||||
if action.Desired == nil {
|
||||
return fmt.Errorf("instance action %d of type %s must have desired state", i, action.Type)
|
||||
}
|
||||
if action.InstanceName == "" {
|
||||
return fmt.Errorf("instance action %d must have an instance name", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone creates a deep copy of the deployment plan
|
||||
func (dp *DeploymentPlan) Clone() *DeploymentPlan {
|
||||
clone := &DeploymentPlan{
|
||||
ConfigName: dp.ConfigName,
|
||||
Summary: dp.Summary,
|
||||
TotalActions: dp.TotalActions,
|
||||
EstimatedDuration: dp.EstimatedDuration,
|
||||
CreatedAt: dp.CreatedAt,
|
||||
DryRun: dp.DryRun,
|
||||
AppAction: dp.AppAction, // Struct copy is sufficient for this use case
|
||||
}
|
||||
|
||||
// Deep copy instance actions
|
||||
clone.InstanceActions = make([]InstanceAction, len(dp.InstanceActions))
|
||||
copy(clone.InstanceActions, dp.InstanceActions)
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// convertNetworkRules converts config network rules to EdgeConnect SecurityRules
|
||||
func convertNetworkRules(network *config.NetworkConfig) []v2.SecurityRule {
|
||||
rules := make([]v2.SecurityRule, len(network.OutboundConnections))
|
||||
|
||||
for i, conn := range network.OutboundConnections {
|
||||
rules[i] = v2.SecurityRule{
|
||||
Protocol: conn.Protocol,
|
||||
PortRangeMin: conn.PortRangeMin,
|
||||
PortRangeMax: conn.PortRangeMax,
|
||||
RemoteCIDR: conn.RemoteCIDR,
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ func TestParseExampleConfig(t *testing.T) {
|
|||
parser := NewParser()
|
||||
|
||||
// Parse the actual example file (now that we've created the manifest file)
|
||||
examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig.yaml")
|
||||
examplePath := filepath.Join("../../sdk/examples/comprehensive/EdgeConnectConfig_v1.yaml")
|
||||
config, parsedManifest, err := parser.ParseFile(examplePath)
|
||||
|
||||
// This should now succeed with full validation
|
||||
|
|
@ -28,14 +28,13 @@ func TestParseExampleConfig(t *testing.T) {
|
|||
|
||||
// Check k8s app configuration
|
||||
require.NotNil(t, config.Spec.K8sApp)
|
||||
assert.Equal(t, "1.0.0", config.Spec.K8sApp.AppVersion)
|
||||
assert.Equal(t, "1.0.0", config.Metadata.AppVersion)
|
||||
// Note: ManifestFile path should be resolved to absolute path
|
||||
assert.Contains(t, config.Spec.K8sApp.ManifestFile, "k8s-deployment.yaml")
|
||||
|
||||
// Check infrastructure template
|
||||
require.Len(t, config.Spec.InfraTemplate, 1)
|
||||
infra := config.Spec.InfraTemplate[0]
|
||||
assert.Equal(t, "edp2", infra.Organization)
|
||||
assert.Equal(t, "EU", infra.Region)
|
||||
assert.Equal(t, "TelekomOP", infra.CloudletOrg)
|
||||
assert.Equal(t, "Munich", infra.CloudletName)
|
||||
|
|
@ -59,7 +58,6 @@ func TestParseExampleConfig(t *testing.T) {
|
|||
|
||||
// Test utility methods
|
||||
assert.Equal(t, "edge-app-demo", config.Metadata.Name)
|
||||
assert.Equal(t, "1.0.0", config.Spec.GetAppVersion())
|
||||
assert.Contains(t, config.Spec.GetManifestFile(), "k8s-deployment.yaml")
|
||||
assert.True(t, config.Spec.IsK8sApp())
|
||||
assert.False(t, config.Spec.IsDockerApp())
|
||||
|
|
@ -72,16 +70,16 @@ func TestValidateExampleStructure(t *testing.T) {
|
|||
config := &EdgeConnectConfig{
|
||||
Kind: "edgeconnect-deployment",
|
||||
Metadata: Metadata{
|
||||
Name: "edge-app-demo",
|
||||
Name: "edge-app-demo",
|
||||
AppVersion: "1.0.0",
|
||||
Organization: "edp2",
|
||||
},
|
||||
Spec: Spec{
|
||||
DockerApp: &DockerApp{ // Use DockerApp to avoid manifest file validation
|
||||
AppVersion: "1.0.0",
|
||||
Image: "nginx:latest",
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
InfraTemplate: []InfraTemplate{
|
||||
{
|
||||
Organization: "edp2",
|
||||
Region: "EU",
|
||||
CloudletOrg: "TelekomOP",
|
||||
CloudletName: "Munich",
|
||||
|
|
|
|||
|
|
@ -32,13 +32,13 @@ func TestConfigParser_ParseBytes(t *testing.T) {
|
|||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "test-app"
|
||||
appVersion: "1.0.0"
|
||||
organization: "testorg"
|
||||
spec:
|
||||
k8sApp:
|
||||
appVersion: "1.0.0"
|
||||
manifestFile: "./test-manifest.yaml"
|
||||
infraTemplate:
|
||||
- organization: "testorg"
|
||||
region: "US"
|
||||
- region: "US"
|
||||
cloudletOrg: "TestOP"
|
||||
cloudletName: "TestCloudlet"
|
||||
flavorName: "small"
|
||||
|
|
@ -52,13 +52,13 @@ spec:
|
|||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "test-app"
|
||||
appVersion: "1.0.0"
|
||||
organization: "testorg"
|
||||
spec:
|
||||
dockerApp:
|
||||
appVersion: "1.0.0"
|
||||
image: "nginx:latest"
|
||||
infraTemplate:
|
||||
- organization: "testorg"
|
||||
region: "US"
|
||||
- region: "US"
|
||||
cloudletOrg: "TestOP"
|
||||
cloudletName: "TestCloudlet"
|
||||
flavorName: "small"
|
||||
|
|
@ -70,13 +70,13 @@ spec:
|
|||
yaml: `
|
||||
metadata:
|
||||
name: "test-app"
|
||||
appVersion: "1.0.0"
|
||||
organization: "testorg"
|
||||
spec:
|
||||
k8sApp:
|
||||
appVersion: "1.0.0"
|
||||
manifestFile: "./test-manifest.yaml"
|
||||
infraTemplate:
|
||||
- organization: "testorg"
|
||||
region: "US"
|
||||
- region: "US"
|
||||
cloudletOrg: "TestOP"
|
||||
cloudletName: "TestCloudlet"
|
||||
flavorName: "small"
|
||||
|
|
@ -90,13 +90,13 @@ spec:
|
|||
kind: invalid-kind
|
||||
metadata:
|
||||
name: "test-app"
|
||||
appVersion: "1.0.0"
|
||||
organization: "testorg"
|
||||
spec:
|
||||
dockerApp:
|
||||
appVersion: "1.0.0"
|
||||
image: "nginx:latest"
|
||||
infraTemplate:
|
||||
- organization: "testorg"
|
||||
region: "US"
|
||||
- region: "US"
|
||||
cloudletOrg: "TestOP"
|
||||
cloudletName: "TestCloudlet"
|
||||
flavorName: "small"
|
||||
|
|
@ -110,10 +110,11 @@ spec:
|
|||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "test-app"
|
||||
appVersion: "1.0.0"
|
||||
organization: "testorg"
|
||||
spec:
|
||||
infraTemplate:
|
||||
- organization: "testorg"
|
||||
region: "US"
|
||||
- region: "US"
|
||||
cloudletOrg: "TestOP"
|
||||
cloudletName: "TestCloudlet"
|
||||
flavorName: "small"
|
||||
|
|
@ -127,17 +128,16 @@ spec:
|
|||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "test-app"
|
||||
appVersion: "1.0.0"
|
||||
organization: "testorg"
|
||||
spec:
|
||||
k8sApp:
|
||||
appVersion: "1.0.0"
|
||||
manifestFile: "./test-manifest.yaml"
|
||||
dockerApp:
|
||||
appName: "test-app"
|
||||
appVersion: "1.0.0"
|
||||
image: "nginx:latest"
|
||||
infraTemplate:
|
||||
- organization: "testorg"
|
||||
region: "US"
|
||||
- region: "US"
|
||||
cloudletOrg: "TestOP"
|
||||
cloudletName: "TestCloudlet"
|
||||
flavorName: "small"
|
||||
|
|
@ -151,9 +151,10 @@ spec:
|
|||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "test-app"
|
||||
appVersion: "1.0.0"
|
||||
organization: "testorg"
|
||||
spec:
|
||||
dockerApp:
|
||||
appVersion: "1.0.0"
|
||||
image: "nginx:latest"
|
||||
infraTemplate: []
|
||||
`,
|
||||
|
|
@ -166,13 +167,13 @@ spec:
|
|||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "test-app"
|
||||
appVersion: "1.0.0"
|
||||
organization: "testorg"
|
||||
spec:
|
||||
dockerApp:
|
||||
appVersion: "1.0.0"
|
||||
image: "nginx:latest"
|
||||
infraTemplate:
|
||||
- organization: "testorg"
|
||||
region: "US"
|
||||
- region: "US"
|
||||
cloudletOrg: "TestOP"
|
||||
cloudletName: "TestCloudlet"
|
||||
flavorName: "small"
|
||||
|
|
@ -222,13 +223,13 @@ func TestConfigParser_ParseFile(t *testing.T) {
|
|||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "test-app"
|
||||
appVersion: "1.0.0"
|
||||
organization: "testorg"
|
||||
spec:
|
||||
dockerApp:
|
||||
appVersion: "1.0.0"
|
||||
image: "nginx:latest"
|
||||
infraTemplate:
|
||||
- organization: "testorg"
|
||||
region: "US"
|
||||
- region: "US"
|
||||
cloudletOrg: "TestOP"
|
||||
cloudletName: "TestCloudlet"
|
||||
flavorName: "small"
|
||||
|
|
@ -284,13 +285,13 @@ func TestConfigParser_RelativePathResolution(t *testing.T) {
|
|||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "test-app"
|
||||
appVersion: "1.0.0"
|
||||
organization: "testorg"
|
||||
spec:
|
||||
k8sApp:
|
||||
appVersion: "1.0.0"
|
||||
manifestFile: "./manifest.yaml"
|
||||
infraTemplate:
|
||||
- organization: "testorg"
|
||||
region: "US"
|
||||
- region: "US"
|
||||
cloudletOrg: "TestOP"
|
||||
cloudletName: "TestCloudlet"
|
||||
flavorName: "small"
|
||||
|
|
@ -321,16 +322,16 @@ func TestEdgeConnectConfig_Validate(t *testing.T) {
|
|||
config: EdgeConnectConfig{
|
||||
Kind: "edgeconnect-deployment",
|
||||
Metadata: Metadata{
|
||||
Name: "test-app",
|
||||
Name: "test-app",
|
||||
AppVersion: "1.0.0",
|
||||
Organization: "testorg",
|
||||
},
|
||||
Spec: Spec{
|
||||
DockerApp: &DockerApp{
|
||||
AppVersion: "1.0.0",
|
||||
Image: "nginx:latest",
|
||||
Image: "nginx:latest",
|
||||
},
|
||||
InfraTemplate: []InfraTemplate{
|
||||
{
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
CloudletOrg: "TestOP",
|
||||
CloudletName: "TestCloudlet",
|
||||
|
|
@ -385,24 +386,60 @@ func TestMetadata_Validate(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
name: "valid metadata",
|
||||
metadata: Metadata{Name: "test-app"},
|
||||
metadata: Metadata{Name: "test-app", AppVersion: "1.0.0", Organization: "testorg"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
metadata: Metadata{Name: ""},
|
||||
metadata: Metadata{Name: "", AppVersion: "1.0.0", Organization: "testorg"},
|
||||
wantErr: true,
|
||||
errMsg: "metadata.name is required",
|
||||
},
|
||||
{
|
||||
name: "name with leading whitespace",
|
||||
metadata: Metadata{Name: " test-app"},
|
||||
metadata: Metadata{Name: " test-app", AppVersion: "1.0.0", Organization: "testorg"},
|
||||
wantErr: true,
|
||||
errMsg: "cannot have leading/trailing whitespace",
|
||||
},
|
||||
{
|
||||
name: "name with trailing whitespace",
|
||||
metadata: Metadata{Name: "test-app "},
|
||||
metadata: Metadata{Name: "test-app ", AppVersion: "1.0.0", Organization: "testorg"},
|
||||
wantErr: true,
|
||||
errMsg: "cannot have leading/trailing whitespace",
|
||||
},
|
||||
{
|
||||
name: "empty app version",
|
||||
metadata: Metadata{Name: "test-app", AppVersion: "", Organization: "testorg"},
|
||||
wantErr: true,
|
||||
errMsg: "metadata.appVersion is required",
|
||||
},
|
||||
{
|
||||
name: "app version with leading whitespace",
|
||||
metadata: Metadata{Name: "test-app", AppVersion: " 1.0.0", Organization: "testorg"},
|
||||
wantErr: true,
|
||||
errMsg: "cannot have leading/trailing whitespace",
|
||||
},
|
||||
{
|
||||
name: "app version with trailing whitespace",
|
||||
metadata: Metadata{Name: "test-app", AppVersion: "1.0.0 ", Organization: "testorg"},
|
||||
wantErr: true,
|
||||
errMsg: "cannot have leading/trailing whitespace",
|
||||
},
|
||||
{
|
||||
name: "empty organization",
|
||||
metadata: Metadata{Name: "test-app", AppVersion: "1.0.0", Organization: ""},
|
||||
wantErr: true,
|
||||
errMsg: "metadata.organization is required",
|
||||
},
|
||||
{
|
||||
name: "organization with leading whitespace",
|
||||
metadata: Metadata{Name: "test-app", AppVersion: "1.0.0", Organization: " testorg"},
|
||||
wantErr: true,
|
||||
errMsg: "cannot have leading/trailing whitespace",
|
||||
},
|
||||
{
|
||||
name: "organization with trailing whitespace",
|
||||
metadata: Metadata{Name: "test-app", AppVersion: "1.0.0", Organization: "testorg "},
|
||||
wantErr: true,
|
||||
errMsg: "cannot have leading/trailing whitespace",
|
||||
},
|
||||
|
|
@ -526,24 +563,20 @@ func TestOutboundConnection_Validate(t *testing.T) {
|
|||
func TestSpec_GetMethods(t *testing.T) {
|
||||
k8sSpec := &Spec{
|
||||
K8sApp: &K8sApp{
|
||||
AppVersion: "1.0.0",
|
||||
ManifestFile: "k8s.yaml",
|
||||
},
|
||||
}
|
||||
|
||||
dockerSpec := &Spec{
|
||||
DockerApp: &DockerApp{
|
||||
AppVersion: "2.0.0",
|
||||
ManifestFile: "docker.yaml",
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "1.0.0", k8sSpec.GetAppVersion())
|
||||
assert.Equal(t, "k8s.yaml", k8sSpec.GetManifestFile())
|
||||
assert.True(t, k8sSpec.IsK8sApp())
|
||||
assert.False(t, k8sSpec.IsDockerApp())
|
||||
|
||||
assert.Equal(t, "2.0.0", dockerSpec.GetAppVersion())
|
||||
assert.Equal(t, "docker.yaml", dockerSpec.GetManifestFile())
|
||||
assert.False(t, dockerSpec.IsK8sApp())
|
||||
assert.True(t, dockerSpec.IsDockerApp())
|
||||
|
|
@ -564,13 +597,13 @@ func TestReadManifestFile(t *testing.T) {
|
|||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "test-app"
|
||||
appVersion: "1.0.0"
|
||||
organization: "testorg"
|
||||
spec:
|
||||
k8sApp:
|
||||
appVersion: "1.0.0"
|
||||
manifestFile: "./manifest.yaml"
|
||||
infraTemplate:
|
||||
- organization: "testorg"
|
||||
region: "US"
|
||||
- region: "US"
|
||||
cloudletOrg: "TestOP"
|
||||
cloudletName: "TestCloudlet"
|
||||
flavorName: "small"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// EdgeConnectConfig represents the top-level configuration structure
|
||||
|
|
@ -18,7 +20,9 @@ type EdgeConnectConfig struct {
|
|||
|
||||
// Metadata contains configuration metadata
|
||||
type Metadata struct {
|
||||
Name string `yaml:"name"`
|
||||
Name string `yaml:"name"`
|
||||
AppVersion string `yaml:"appVersion"`
|
||||
Organization string `yaml:"organization"`
|
||||
}
|
||||
|
||||
// Spec defines the application and infrastructure specification
|
||||
|
|
@ -32,20 +36,17 @@ type Spec struct {
|
|||
|
||||
// K8sApp defines Kubernetes application configuration
|
||||
type K8sApp struct {
|
||||
AppVersion string `yaml:"appVersion"`
|
||||
ManifestFile string `yaml:"manifestFile"`
|
||||
}
|
||||
|
||||
// DockerApp defines Docker application configuration
|
||||
type DockerApp struct {
|
||||
AppVersion string `yaml:"appVersion"`
|
||||
ManifestFile string `yaml:"manifestFile"`
|
||||
Image string `yaml:"image"`
|
||||
}
|
||||
|
||||
// InfraTemplate defines infrastructure deployment targets
|
||||
type InfraTemplate struct {
|
||||
Organization string `yaml:"organization"`
|
||||
Region string `yaml:"region"`
|
||||
CloudletOrg string `yaml:"cloudletOrg"`
|
||||
CloudletName string `yaml:"cloudletName"`
|
||||
|
|
@ -99,10 +100,75 @@ func (c *EdgeConnectConfig) GetImagePath() string {
|
|||
if c.Spec.IsDockerApp() && c.Spec.DockerApp.Image != "" {
|
||||
return c.Spec.DockerApp.Image
|
||||
}
|
||||
// Default for kubernetes apps
|
||||
|
||||
// For kubernetes apps, extract image from manifest
|
||||
if c.Spec.IsK8sApp() && c.Spec.K8sApp.ManifestFile != "" {
|
||||
if image, err := extractImageFromK8sManifest(c.Spec.K8sApp.ManifestFile); err == nil && image != "" {
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback default for kubernetes apps
|
||||
return "https://registry-1.docker.io/library/nginx:latest"
|
||||
}
|
||||
|
||||
// extractImageFromK8sManifest extracts the container image from a Kubernetes manifest
|
||||
func extractImageFromK8sManifest(manifestPath string) (string, error) {
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read manifest: %w", err)
|
||||
}
|
||||
|
||||
// Parse multi-document YAML
|
||||
decoder := yaml.NewDecoder(strings.NewReader(string(data)))
|
||||
|
||||
for {
|
||||
var doc map[string]interface{}
|
||||
if err := decoder.Decode(&doc); err != nil {
|
||||
break // End of documents or error
|
||||
}
|
||||
|
||||
// Check if this is a Deployment
|
||||
kind, ok := doc["kind"].(string)
|
||||
if !ok || kind != "Deployment" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Navigate to spec.template.spec.containers[0].image
|
||||
spec, ok := doc["spec"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
template, ok := spec["template"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
templateSpec, ok := template["spec"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
containers, ok := templateSpec["containers"].([]interface{})
|
||||
if !ok || len(containers) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
firstContainer, ok := containers[0].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
image, ok := firstContainer["image"].(string)
|
||||
if ok && image != "" {
|
||||
return image, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no image found in Deployment manifest")
|
||||
}
|
||||
|
||||
// Validate validates metadata fields
|
||||
func (m *Metadata) Validate() error {
|
||||
if m.Name == "" {
|
||||
|
|
@ -113,6 +179,22 @@ func (m *Metadata) Validate() error {
|
|||
return fmt.Errorf("metadata.name cannot have leading/trailing whitespace")
|
||||
}
|
||||
|
||||
if m.AppVersion == "" {
|
||||
return fmt.Errorf("metadata.appVersion is required")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.AppVersion) != m.AppVersion {
|
||||
return fmt.Errorf("metadata.appVersion cannot have leading/trailing whitespace")
|
||||
}
|
||||
|
||||
if m.Organization == "" {
|
||||
return fmt.Errorf("metadata.organization is required")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Organization) != m.Organization {
|
||||
return fmt.Errorf("metadata.Organization cannot have leading/trailing whitespace")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -171,10 +253,6 @@ func (s *Spec) Validate() error {
|
|||
|
||||
// Validate validates k8s app configuration
|
||||
func (k *K8sApp) Validate() error {
|
||||
if k.AppVersion == "" {
|
||||
return fmt.Errorf("appVersion is required")
|
||||
}
|
||||
|
||||
if k.ManifestFile == "" {
|
||||
return fmt.Errorf("manifestFile is required")
|
||||
}
|
||||
|
|
@ -184,29 +262,15 @@ func (k *K8sApp) Validate() error {
|
|||
return fmt.Errorf("manifestFile does not exist: %s", k.ManifestFile)
|
||||
}
|
||||
|
||||
// Validate version format
|
||||
if strings.TrimSpace(k.AppVersion) != k.AppVersion {
|
||||
return fmt.Errorf("appVersion cannot have leading/trailing whitespace")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates docker app configuration
|
||||
func (d *DockerApp) Validate() error {
|
||||
if d.AppVersion == "" {
|
||||
return fmt.Errorf("appVersion is required")
|
||||
}
|
||||
|
||||
if d.Image == "" {
|
||||
return fmt.Errorf("image is required")
|
||||
}
|
||||
|
||||
// Validate version format
|
||||
if strings.TrimSpace(d.AppVersion) != d.AppVersion {
|
||||
return fmt.Errorf("appVersion cannot have leading/trailing whitespace")
|
||||
}
|
||||
|
||||
// Check if manifest file exists if specified
|
||||
if d.ManifestFile != "" {
|
||||
if _, err := os.Stat(d.ManifestFile); os.IsNotExist(err) {
|
||||
|
|
@ -219,10 +283,6 @@ func (d *DockerApp) Validate() error {
|
|||
|
||||
// Validate validates infrastructure template configuration
|
||||
func (i *InfraTemplate) Validate() error {
|
||||
if i.Organization == "" {
|
||||
return fmt.Errorf("organization is required")
|
||||
}
|
||||
|
||||
if i.Region == "" {
|
||||
return fmt.Errorf("region is required")
|
||||
}
|
||||
|
|
@ -241,7 +301,6 @@ func (i *InfraTemplate) Validate() error {
|
|||
|
||||
// Validate no leading/trailing whitespace
|
||||
fields := map[string]string{
|
||||
"organization": i.Organization,
|
||||
"region": i.Region,
|
||||
"cloudletOrg": i.CloudletOrg,
|
||||
"cloudletName": i.CloudletName,
|
||||
|
|
@ -326,17 +385,6 @@ func (d *DockerApp) GetManifestPath(configDir string) string {
|
|||
return filepath.Join(configDir, d.ManifestFile)
|
||||
}
|
||||
|
||||
// GetAppVersion returns the application version from the active app type
|
||||
func (s *Spec) GetAppVersion() string {
|
||||
if s.K8sApp != nil {
|
||||
return s.K8sApp.AppVersion
|
||||
}
|
||||
if s.DockerApp != nil {
|
||||
return s.DockerApp.AppVersion
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetManifestFile returns the manifest file path from the active app type
|
||||
func (s *Spec) GetManifestFile() string {
|
||||
if s.K8sApp != nil {
|
||||
|
|
|
|||
166
internal/delete/v1/manager.go
Normal file
166
internal/delete/v1/manager.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// ABOUTME: Resource management for EdgeConnect delete command with deletion execution
|
||||
// ABOUTME: Handles actual deletion operations with proper ordering (instances first, then app)
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
// ResourceManagerInterface defines the interface for resource management
|
||||
type ResourceManagerInterface interface {
|
||||
// ExecuteDeletion executes a deletion plan
|
||||
ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error)
|
||||
}
|
||||
|
||||
// EdgeConnectResourceManager implements resource management for EdgeConnect
|
||||
type EdgeConnectResourceManager struct {
|
||||
client EdgeConnectClientInterface
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// Logger interface for deletion logging
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
// ResourceManagerOptions configures the resource manager behavior
|
||||
type ResourceManagerOptions struct {
|
||||
// Logger for deletion operations
|
||||
Logger Logger
|
||||
}
|
||||
|
||||
// DefaultResourceManagerOptions returns sensible defaults
|
||||
func DefaultResourceManagerOptions() ResourceManagerOptions {
|
||||
return ResourceManagerOptions{
|
||||
Logger: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// NewResourceManager creates a new EdgeConnect resource manager
|
||||
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
||||
options := DefaultResourceManagerOptions()
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
return &EdgeConnectResourceManager{
|
||||
client: client,
|
||||
logger: options.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets a logger for deletion operations
|
||||
func WithLogger(logger Logger) func(*ResourceManagerOptions) {
|
||||
return func(opts *ResourceManagerOptions) {
|
||||
opts.Logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteDeletion executes a deletion plan
|
||||
// Important: Instances must be deleted before the app
|
||||
func (rm *EdgeConnectResourceManager) ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error) {
|
||||
startTime := time.Now()
|
||||
rm.logf("Starting deletion: %s", plan.ConfigName)
|
||||
|
||||
result := &DeletionResult{
|
||||
Plan: plan,
|
||||
Success: true,
|
||||
CompletedActions: []DeletionActionResult{},
|
||||
FailedActions: []DeletionActionResult{},
|
||||
}
|
||||
|
||||
// If plan is empty, return success immediately
|
||||
if plan.IsEmpty() {
|
||||
rm.logf("No resources to delete")
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Step 1: Delete all instances first
|
||||
for _, instance := range plan.InstancesToDelete {
|
||||
actionStart := time.Now()
|
||||
rm.logf("Deleting instance: %s", instance.Name)
|
||||
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: instance.Organization,
|
||||
Name: instance.Name,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: instance.CloudletOrg,
|
||||
Name: instance.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
err := rm.client.DeleteAppInstance(ctx, instanceKey, instance.Region)
|
||||
actionResult := DeletionActionResult{
|
||||
Type: "instance",
|
||||
Target: instance.Name,
|
||||
Duration: time.Since(actionStart),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
rm.logf("Failed to delete instance %s: %v", instance.Name, err)
|
||||
actionResult.Success = false
|
||||
actionResult.Error = err
|
||||
result.FailedActions = append(result.FailedActions, actionResult)
|
||||
result.Success = false
|
||||
result.Error = fmt.Errorf("failed to delete instance %s: %w", instance.Name, err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
rm.logf("Successfully deleted instance: %s", instance.Name)
|
||||
actionResult.Success = true
|
||||
result.CompletedActions = append(result.CompletedActions, actionResult)
|
||||
}
|
||||
|
||||
// Step 2: Delete the app (only after all instances are deleted)
|
||||
if plan.AppToDelete != nil {
|
||||
actionStart := time.Now()
|
||||
app := plan.AppToDelete
|
||||
rm.logf("Deleting app: %s version %s", app.Name, app.Version)
|
||||
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: app.Organization,
|
||||
Name: app.Name,
|
||||
Version: app.Version,
|
||||
}
|
||||
|
||||
err := rm.client.DeleteApp(ctx, appKey, app.Region)
|
||||
actionResult := DeletionActionResult{
|
||||
Type: "app",
|
||||
Target: fmt.Sprintf("%s:%s", app.Name, app.Version),
|
||||
Duration: time.Since(actionStart),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
rm.logf("Failed to delete app %s: %v", app.Name, err)
|
||||
actionResult.Success = false
|
||||
actionResult.Error = err
|
||||
result.FailedActions = append(result.FailedActions, actionResult)
|
||||
result.Success = false
|
||||
result.Error = fmt.Errorf("failed to delete app %s: %w", app.Name, err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
rm.logf("Successfully deleted app: %s", app.Name)
|
||||
actionResult.Success = true
|
||||
result.CompletedActions = append(result.CompletedActions, actionResult)
|
||||
}
|
||||
|
||||
result.Duration = time.Since(startTime)
|
||||
rm.logf("Deletion completed successfully in %v", result.Duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// logf logs a message if a logger is configured
|
||||
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
|
||||
if rm.logger != nil {
|
||||
rm.logger.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
229
internal/delete/v1/planner.go
Normal file
229
internal/delete/v1/planner.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// ABOUTME: Deletion planner for EdgeConnect delete command
|
||||
// ABOUTME: Analyzes current state to identify resources for deletion
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect"
|
||||
)
|
||||
|
||||
// EdgeConnectClientInterface defines the methods needed for deletion planning
|
||||
type EdgeConnectClientInterface interface {
|
||||
ShowApp(ctx context.Context, appKey edgeconnect.AppKey, region string) (edgeconnect.App, error)
|
||||
ShowAppInstances(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, appKey edgeconnect.AppKey, region string) ([]edgeconnect.AppInstance, error)
|
||||
DeleteApp(ctx context.Context, appKey edgeconnect.AppKey, region string) error
|
||||
DeleteAppInstance(ctx context.Context, instanceKey edgeconnect.AppInstanceKey, region string) error
|
||||
}
|
||||
|
||||
// Planner defines the interface for deletion planning
|
||||
type Planner interface {
|
||||
// Plan analyzes the configuration and current state to generate a deletion plan
|
||||
Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error)
|
||||
|
||||
// PlanWithOptions allows customization of planning behavior
|
||||
PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error)
|
||||
}
|
||||
|
||||
// PlanOptions provides configuration for the planning process
|
||||
type PlanOptions struct {
|
||||
// DryRun indicates this is a planning-only operation
|
||||
DryRun bool
|
||||
|
||||
// Timeout for API operations
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultPlanOptions returns sensible default planning options
|
||||
func DefaultPlanOptions() PlanOptions {
|
||||
return PlanOptions{
|
||||
DryRun: false,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
|
||||
type EdgeConnectPlanner struct {
|
||||
client EdgeConnectClientInterface
|
||||
}
|
||||
|
||||
// NewPlanner creates a new EdgeConnect deletion planner
|
||||
func NewPlanner(client EdgeConnectClientInterface) Planner {
|
||||
return &EdgeConnectPlanner{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// Plan analyzes the configuration and generates a deletion plan
|
||||
func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) {
|
||||
return p.PlanWithOptions(ctx, config, DefaultPlanOptions())
|
||||
}
|
||||
|
||||
// PlanWithOptions generates a deletion plan with custom options
|
||||
func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) {
|
||||
startTime := time.Now()
|
||||
var warnings []string
|
||||
|
||||
// Create the deletion plan structure
|
||||
plan := &DeletionPlan{
|
||||
ConfigName: config.Metadata.Name,
|
||||
CreatedAt: startTime,
|
||||
DryRun: opts.DryRun,
|
||||
}
|
||||
|
||||
// Get the region from the first infra template
|
||||
region := config.Spec.InfraTemplate[0].Region
|
||||
|
||||
// Step 1: Check if instances exist
|
||||
instancesResult := p.findInstancesToDelete(ctx, config, region)
|
||||
plan.InstancesToDelete = instancesResult.instances
|
||||
if instancesResult.err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("Error querying instances: %v", instancesResult.err))
|
||||
}
|
||||
|
||||
// Step 2: Check if app exists
|
||||
appResult := p.findAppToDelete(ctx, config, region)
|
||||
plan.AppToDelete = appResult.app
|
||||
if appResult.err != nil && !isNotFoundError(appResult.err) {
|
||||
warnings = append(warnings, fmt.Sprintf("Error querying app: %v", appResult.err))
|
||||
}
|
||||
|
||||
// Step 3: Calculate plan metadata
|
||||
p.calculatePlanMetadata(plan)
|
||||
|
||||
// Step 4: Generate summary
|
||||
plan.Summary = plan.GenerateSummary()
|
||||
|
||||
return &PlanResult{
|
||||
Plan: plan,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type appQueryResult struct {
|
||||
app *AppDeletion
|
||||
err error
|
||||
}
|
||||
|
||||
type instancesQueryResult struct {
|
||||
instances []InstanceDeletion
|
||||
err error
|
||||
}
|
||||
|
||||
// findAppToDelete checks if the app exists and should be deleted
|
||||
func (p *EdgeConnectPlanner) findAppToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) appQueryResult {
|
||||
appKey := edgeconnect.AppKey{
|
||||
Organization: config.Metadata.Organization,
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Metadata.AppVersion,
|
||||
}
|
||||
|
||||
app, err := p.client.ShowApp(ctx, appKey, region)
|
||||
if err != nil {
|
||||
if isNotFoundError(err) {
|
||||
return appQueryResult{app: nil, err: nil}
|
||||
}
|
||||
return appQueryResult{app: nil, err: err}
|
||||
}
|
||||
|
||||
return appQueryResult{
|
||||
app: &AppDeletion{
|
||||
Name: app.Key.Name,
|
||||
Version: app.Key.Version,
|
||||
Organization: app.Key.Organization,
|
||||
Region: region,
|
||||
},
|
||||
err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// findInstancesToDelete finds all instances that match the config
|
||||
func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) instancesQueryResult {
|
||||
var allInstances []InstanceDeletion
|
||||
|
||||
// Query instances for each infra template
|
||||
for _, infra := range config.Spec.InfraTemplate {
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
Organization: config.Metadata.Organization,
|
||||
Name: generateInstanceName(config.Metadata.Name, config.Metadata.AppVersion),
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
Organization: infra.CloudletOrg,
|
||||
Name: infra.CloudletName,
|
||||
},
|
||||
}
|
||||
appKey := edgeconnect.AppKey{Name: config.Metadata.Name}
|
||||
|
||||
instances, err := p.client.ShowAppInstances(ctx, instanceKey, appKey, infra.Region)
|
||||
if err != nil {
|
||||
// If it's a not found error, just continue
|
||||
if isNotFoundError(err) {
|
||||
continue
|
||||
}
|
||||
return instancesQueryResult{instances: nil, err: err}
|
||||
}
|
||||
|
||||
// Add found instances to the list
|
||||
for _, inst := range instances {
|
||||
allInstances = append(allInstances, InstanceDeletion{
|
||||
Name: inst.Key.Name,
|
||||
Organization: inst.Key.Organization,
|
||||
Region: infra.Region,
|
||||
CloudletOrg: inst.Key.CloudletKey.Organization,
|
||||
CloudletName: inst.Key.CloudletKey.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return instancesQueryResult{
|
||||
instances: allInstances,
|
||||
err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// calculatePlanMetadata calculates the total actions and estimated duration
|
||||
func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeletionPlan) {
|
||||
totalActions := 0
|
||||
|
||||
if plan.AppToDelete != nil {
|
||||
totalActions++
|
||||
}
|
||||
|
||||
totalActions += len(plan.InstancesToDelete)
|
||||
|
||||
plan.TotalActions = totalActions
|
||||
|
||||
// Estimate duration: ~5 seconds per instance, ~3 seconds for app
|
||||
estimatedSeconds := len(plan.InstancesToDelete) * 5
|
||||
if plan.AppToDelete != nil {
|
||||
estimatedSeconds += 3
|
||||
}
|
||||
plan.EstimatedDuration = time.Duration(estimatedSeconds) * time.Second
|
||||
}
|
||||
|
||||
// generateInstanceName creates an instance name from app name and version
|
||||
func generateInstanceName(appName, appVersion string) string {
|
||||
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
|
||||
}
|
||||
|
||||
// isNotFoundError checks if an error is a 404 not found error
|
||||
func isNotFoundError(err error) bool {
|
||||
if apiErr, ok := err.(*edgeconnect.APIError); ok {
|
||||
return apiErr.StatusCode == 404
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PlanResult represents the result of a deletion planning operation
|
||||
type PlanResult struct {
|
||||
// Plan is the generated deletion plan
|
||||
Plan *DeletionPlan
|
||||
|
||||
// Error if planning failed
|
||||
Error error
|
||||
|
||||
// Warnings encountered during planning
|
||||
Warnings []string
|
||||
}
|
||||
157
internal/delete/v1/types.go
Normal file
157
internal/delete/v1/types.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// ABOUTME: Deletion planning types for EdgeConnect delete command
|
||||
// ABOUTME: Defines structures for deletion plans and deletion results
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeletionPlan represents the complete deletion plan for a configuration
|
||||
type DeletionPlan struct {
|
||||
// ConfigName is the name from metadata
|
||||
ConfigName string
|
||||
|
||||
// AppToDelete defines the app that will be deleted (nil if app doesn't exist)
|
||||
AppToDelete *AppDeletion
|
||||
|
||||
// InstancesToDelete defines the instances that will be deleted
|
||||
InstancesToDelete []InstanceDeletion
|
||||
|
||||
// Summary provides a human-readable summary of the plan
|
||||
Summary string
|
||||
|
||||
// TotalActions is the count of all actions that will be performed
|
||||
TotalActions int
|
||||
|
||||
// EstimatedDuration is the estimated time to complete the deletion
|
||||
EstimatedDuration time.Duration
|
||||
|
||||
// CreatedAt timestamp when the plan was created
|
||||
CreatedAt time.Time
|
||||
|
||||
// DryRun indicates if this is a dry-run plan
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// AppDeletion represents an application to be deleted
|
||||
type AppDeletion struct {
|
||||
// Name of the application
|
||||
Name string
|
||||
|
||||
// Version of the application
|
||||
Version string
|
||||
|
||||
// Organization that owns the app
|
||||
Organization string
|
||||
|
||||
// Region where the app is deployed
|
||||
Region string
|
||||
}
|
||||
|
||||
// InstanceDeletion represents an application instance to be deleted
|
||||
type InstanceDeletion struct {
|
||||
// Name of the instance
|
||||
Name string
|
||||
|
||||
// Organization that owns the instance
|
||||
Organization string
|
||||
|
||||
// Region where the instance is deployed
|
||||
Region string
|
||||
|
||||
// CloudletOrg that hosts the cloudlet
|
||||
CloudletOrg string
|
||||
|
||||
// CloudletName where the instance is running
|
||||
CloudletName string
|
||||
}
|
||||
|
||||
// DeletionResult represents the result of a deletion operation
|
||||
type DeletionResult struct {
|
||||
// Plan that was executed
|
||||
Plan *DeletionPlan
|
||||
|
||||
// Success indicates if the deletion was successful
|
||||
Success bool
|
||||
|
||||
// CompletedActions lists actions that were successfully completed
|
||||
CompletedActions []DeletionActionResult
|
||||
|
||||
// FailedActions lists actions that failed
|
||||
FailedActions []DeletionActionResult
|
||||
|
||||
// Error that caused the deletion to fail (if any)
|
||||
Error error
|
||||
|
||||
// Duration taken to execute the plan
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// DeletionActionResult represents the result of executing a single deletion action
|
||||
type DeletionActionResult struct {
|
||||
// Type of resource that was deleted ("app" or "instance")
|
||||
Type string
|
||||
|
||||
// Target describes what was being deleted
|
||||
Target string
|
||||
|
||||
// Success indicates if the action succeeded
|
||||
Success bool
|
||||
|
||||
// Error if the action failed
|
||||
Error error
|
||||
|
||||
// Duration taken to complete the action
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the deletion plan has no actions to perform
|
||||
func (dp *DeletionPlan) IsEmpty() bool {
|
||||
return dp.AppToDelete == nil && len(dp.InstancesToDelete) == 0
|
||||
}
|
||||
|
||||
// GenerateSummary creates a human-readable summary of the deletion plan
|
||||
func (dp *DeletionPlan) GenerateSummary() string {
|
||||
if dp.IsEmpty() {
|
||||
return "No resources found to delete"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Deletion plan for '%s':\n", dp.ConfigName))
|
||||
|
||||
// Instance actions
|
||||
if len(dp.InstancesToDelete) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- DELETE %d instance(s)\n", len(dp.InstancesToDelete)))
|
||||
cloudletSet := make(map[string]bool)
|
||||
for _, inst := range dp.InstancesToDelete {
|
||||
key := fmt.Sprintf("%s:%s", inst.CloudletOrg, inst.CloudletName)
|
||||
cloudletSet[key] = true
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Across %d cloudlet(s)\n", len(cloudletSet)))
|
||||
}
|
||||
|
||||
// App action
|
||||
if dp.AppToDelete != nil {
|
||||
sb.WriteString(fmt.Sprintf("- DELETE application '%s' version %s\n",
|
||||
dp.AppToDelete.Name, dp.AppToDelete.Version))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Validate checks if the deletion plan is valid
|
||||
func (dp *DeletionPlan) Validate() error {
|
||||
if dp.ConfigName == "" {
|
||||
return fmt.Errorf("deletion plan must have a config name")
|
||||
}
|
||||
|
||||
if dp.IsEmpty() {
|
||||
return fmt.Errorf("deletion plan has no resources to delete")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
166
internal/delete/v2/manager.go
Normal file
166
internal/delete/v2/manager.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// ABOUTME: Resource management for EdgeConnect delete command with deletion execution
|
||||
// ABOUTME: Handles actual deletion operations with proper ordering (instances first, then app)
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
// ResourceManagerInterface defines the interface for resource management
|
||||
type ResourceManagerInterface interface {
|
||||
// ExecuteDeletion executes a deletion plan
|
||||
ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error)
|
||||
}
|
||||
|
||||
// EdgeConnectResourceManager implements resource management for EdgeConnect
|
||||
type EdgeConnectResourceManager struct {
|
||||
client EdgeConnectClientInterface
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// Logger interface for deletion logging
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
// ResourceManagerOptions configures the resource manager behavior
|
||||
type ResourceManagerOptions struct {
|
||||
// Logger for deletion operations
|
||||
Logger Logger
|
||||
}
|
||||
|
||||
// DefaultResourceManagerOptions returns sensible defaults
|
||||
func DefaultResourceManagerOptions() ResourceManagerOptions {
|
||||
return ResourceManagerOptions{
|
||||
Logger: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// NewResourceManager creates a new EdgeConnect resource manager
|
||||
func NewResourceManager(client EdgeConnectClientInterface, opts ...func(*ResourceManagerOptions)) ResourceManagerInterface {
|
||||
options := DefaultResourceManagerOptions()
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
return &EdgeConnectResourceManager{
|
||||
client: client,
|
||||
logger: options.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets a logger for deletion operations
|
||||
func WithLogger(logger Logger) func(*ResourceManagerOptions) {
|
||||
return func(opts *ResourceManagerOptions) {
|
||||
opts.Logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteDeletion executes a deletion plan
|
||||
// Important: Instances must be deleted before the app
|
||||
func (rm *EdgeConnectResourceManager) ExecuteDeletion(ctx context.Context, plan *DeletionPlan) (*DeletionResult, error) {
|
||||
startTime := time.Now()
|
||||
rm.logf("Starting deletion: %s", plan.ConfigName)
|
||||
|
||||
result := &DeletionResult{
|
||||
Plan: plan,
|
||||
Success: true,
|
||||
CompletedActions: []DeletionActionResult{},
|
||||
FailedActions: []DeletionActionResult{},
|
||||
}
|
||||
|
||||
// If plan is empty, return success immediately
|
||||
if plan.IsEmpty() {
|
||||
rm.logf("No resources to delete")
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Step 1: Delete all instances first
|
||||
for _, instance := range plan.InstancesToDelete {
|
||||
actionStart := time.Now()
|
||||
rm.logf("Deleting instance: %s", instance.Name)
|
||||
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: instance.Organization,
|
||||
Name: instance.Name,
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: instance.CloudletOrg,
|
||||
Name: instance.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
err := rm.client.DeleteAppInstance(ctx, instanceKey, instance.Region)
|
||||
actionResult := DeletionActionResult{
|
||||
Type: "instance",
|
||||
Target: instance.Name,
|
||||
Duration: time.Since(actionStart),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
rm.logf("Failed to delete instance %s: %v", instance.Name, err)
|
||||
actionResult.Success = false
|
||||
actionResult.Error = err
|
||||
result.FailedActions = append(result.FailedActions, actionResult)
|
||||
result.Success = false
|
||||
result.Error = fmt.Errorf("failed to delete instance %s: %w", instance.Name, err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
rm.logf("Successfully deleted instance: %s", instance.Name)
|
||||
actionResult.Success = true
|
||||
result.CompletedActions = append(result.CompletedActions, actionResult)
|
||||
}
|
||||
|
||||
// Step 2: Delete the app (only after all instances are deleted)
|
||||
if plan.AppToDelete != nil {
|
||||
actionStart := time.Now()
|
||||
app := plan.AppToDelete
|
||||
rm.logf("Deleting app: %s version %s", app.Name, app.Version)
|
||||
|
||||
appKey := v2.AppKey{
|
||||
Organization: app.Organization,
|
||||
Name: app.Name,
|
||||
Version: app.Version,
|
||||
}
|
||||
|
||||
err := rm.client.DeleteApp(ctx, appKey, app.Region)
|
||||
actionResult := DeletionActionResult{
|
||||
Type: "app",
|
||||
Target: fmt.Sprintf("%s:%s", app.Name, app.Version),
|
||||
Duration: time.Since(actionStart),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
rm.logf("Failed to delete app %s: %v", app.Name, err)
|
||||
actionResult.Success = false
|
||||
actionResult.Error = err
|
||||
result.FailedActions = append(result.FailedActions, actionResult)
|
||||
result.Success = false
|
||||
result.Error = fmt.Errorf("failed to delete app %s: %w", app.Name, err)
|
||||
result.Duration = time.Since(startTime)
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
rm.logf("Successfully deleted app: %s", app.Name)
|
||||
actionResult.Success = true
|
||||
result.CompletedActions = append(result.CompletedActions, actionResult)
|
||||
}
|
||||
|
||||
result.Duration = time.Since(startTime)
|
||||
rm.logf("Deletion completed successfully in %v", result.Duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// logf logs a message if a logger is configured
|
||||
func (rm *EdgeConnectResourceManager) logf(format string, v ...interface{}) {
|
||||
if rm.logger != nil {
|
||||
rm.logger.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
200
internal/delete/v2/manager_test.go
Normal file
200
internal/delete/v2/manager_test.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// ABOUTME: Tests for EdgeConnect deletion manager with mock scenarios
|
||||
// ABOUTME: Tests deletion execution and error handling with mock clients
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockResourceClient for testing deletion manager
|
||||
type MockResourceClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return v2.App{}, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(v2.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]v2.AppInstance), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockResourceClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// TestLogger implements Logger interface for testing
|
||||
type TestLogger struct {
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (l *TestLogger) Printf(format string, v ...interface{}) {
|
||||
l.messages = append(l.messages, fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
func TestNewResourceManager(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
|
||||
assert.NotNil(t, manager)
|
||||
}
|
||||
|
||||
func TestWithLogger(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger))
|
||||
|
||||
// Cast to implementation to check logger was set
|
||||
impl := manager.(*EdgeConnectResourceManager)
|
||||
assert.Equal(t, logger, impl.logger)
|
||||
}
|
||||
|
||||
func createTestDeletionPlan() *DeletionPlan {
|
||||
return &DeletionPlan{
|
||||
ConfigName: "test-deletion",
|
||||
AppToDelete: &AppDeletion{
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
},
|
||||
InstancesToDelete: []InstanceDeletion{
|
||||
{
|
||||
Name: "test-app-1.0.0-instance",
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
CloudletOrg: "cloudletorg",
|
||||
CloudletName: "cloudlet1",
|
||||
},
|
||||
},
|
||||
TotalActions: 2,
|
||||
EstimatedDuration: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDeletion_Success(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger))
|
||||
|
||||
plan := createTestDeletionPlan()
|
||||
|
||||
// Mock successful deletion operations
|
||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(nil)
|
||||
mockClient.On("DeleteApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ExecuteDeletion(ctx, plan)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 2) // 1 instance + 1 app
|
||||
assert.Len(t, result.FailedActions, 0)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestExecuteDeletion_InstanceDeleteFails(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger))
|
||||
|
||||
plan := createTestDeletionPlan()
|
||||
|
||||
// Mock instance deletion failure
|
||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(fmt.Errorf("instance deletion failed"))
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ExecuteDeletion(ctx, plan)
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.Success)
|
||||
assert.Len(t, result.FailedActions, 1)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestExecuteDeletion_OnlyInstances(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
logger := &TestLogger{}
|
||||
manager := NewResourceManager(mockClient, WithLogger(logger))
|
||||
|
||||
plan := &DeletionPlan{
|
||||
ConfigName: "test-deletion",
|
||||
AppToDelete: nil, // No app to delete
|
||||
InstancesToDelete: []InstanceDeletion{
|
||||
{
|
||||
Name: "test-app-1.0.0-instance",
|
||||
Organization: "testorg",
|
||||
Region: "US",
|
||||
CloudletOrg: "cloudletorg",
|
||||
CloudletName: "cloudlet1",
|
||||
},
|
||||
},
|
||||
TotalActions: 1,
|
||||
EstimatedDuration: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Mock successful instance deletion
|
||||
mockClient.On("DeleteAppInstance", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ExecuteDeletion(ctx, plan)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 1)
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestExecuteDeletion_EmptyPlan(t *testing.T) {
|
||||
mockClient := &MockResourceClient{}
|
||||
manager := NewResourceManager(mockClient)
|
||||
|
||||
plan := &DeletionPlan{
|
||||
ConfigName: "test-deletion",
|
||||
AppToDelete: nil,
|
||||
InstancesToDelete: []InstanceDeletion{},
|
||||
TotalActions: 0,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := manager.ExecuteDeletion(ctx, plan)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Success)
|
||||
assert.Len(t, result.CompletedActions, 0)
|
||||
assert.Len(t, result.FailedActions, 0)
|
||||
}
|
||||
229
internal/delete/v2/planner.go
Normal file
229
internal/delete/v2/planner.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// ABOUTME: Deletion planner for EdgeConnect delete command
|
||||
// ABOUTME: Analyzes current state to identify resources for deletion
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
// EdgeConnectClientInterface defines the methods needed for deletion planning
|
||||
type EdgeConnectClientInterface interface {
|
||||
ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error)
|
||||
ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error)
|
||||
DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error
|
||||
DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error
|
||||
}
|
||||
|
||||
// Planner defines the interface for deletion planning
|
||||
type Planner interface {
|
||||
// Plan analyzes the configuration and current state to generate a deletion plan
|
||||
Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error)
|
||||
|
||||
// PlanWithOptions allows customization of planning behavior
|
||||
PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error)
|
||||
}
|
||||
|
||||
// PlanOptions provides configuration for the planning process
|
||||
type PlanOptions struct {
|
||||
// DryRun indicates this is a planning-only operation
|
||||
DryRun bool
|
||||
|
||||
// Timeout for API operations
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultPlanOptions returns sensible default planning options
|
||||
func DefaultPlanOptions() PlanOptions {
|
||||
return PlanOptions{
|
||||
DryRun: false,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// EdgeConnectPlanner implements the Planner interface for EdgeConnect
|
||||
type EdgeConnectPlanner struct {
|
||||
client EdgeConnectClientInterface
|
||||
}
|
||||
|
||||
// NewPlanner creates a new EdgeConnect deletion planner
|
||||
func NewPlanner(client EdgeConnectClientInterface) Planner {
|
||||
return &EdgeConnectPlanner{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// Plan analyzes the configuration and generates a deletion plan
|
||||
func (p *EdgeConnectPlanner) Plan(ctx context.Context, config *config.EdgeConnectConfig) (*PlanResult, error) {
|
||||
return p.PlanWithOptions(ctx, config, DefaultPlanOptions())
|
||||
}
|
||||
|
||||
// PlanWithOptions generates a deletion plan with custom options
|
||||
func (p *EdgeConnectPlanner) PlanWithOptions(ctx context.Context, config *config.EdgeConnectConfig, opts PlanOptions) (*PlanResult, error) {
|
||||
startTime := time.Now()
|
||||
var warnings []string
|
||||
|
||||
// Create the deletion plan structure
|
||||
plan := &DeletionPlan{
|
||||
ConfigName: config.Metadata.Name,
|
||||
CreatedAt: startTime,
|
||||
DryRun: opts.DryRun,
|
||||
}
|
||||
|
||||
// Get the region from the first infra template
|
||||
region := config.Spec.InfraTemplate[0].Region
|
||||
|
||||
// Step 1: Check if instances exist
|
||||
instancesResult := p.findInstancesToDelete(ctx, config, region)
|
||||
plan.InstancesToDelete = instancesResult.instances
|
||||
if instancesResult.err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("Error querying instances: %v", instancesResult.err))
|
||||
}
|
||||
|
||||
// Step 2: Check if app exists
|
||||
appResult := p.findAppToDelete(ctx, config, region)
|
||||
plan.AppToDelete = appResult.app
|
||||
if appResult.err != nil && !isNotFoundError(appResult.err) {
|
||||
warnings = append(warnings, fmt.Sprintf("Error querying app: %v", appResult.err))
|
||||
}
|
||||
|
||||
// Step 3: Calculate plan metadata
|
||||
p.calculatePlanMetadata(plan)
|
||||
|
||||
// Step 4: Generate summary
|
||||
plan.Summary = plan.GenerateSummary()
|
||||
|
||||
return &PlanResult{
|
||||
Plan: plan,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type appQueryResult struct {
|
||||
app *AppDeletion
|
||||
err error
|
||||
}
|
||||
|
||||
type instancesQueryResult struct {
|
||||
instances []InstanceDeletion
|
||||
err error
|
||||
}
|
||||
|
||||
// findAppToDelete checks if the app exists and should be deleted
|
||||
func (p *EdgeConnectPlanner) findAppToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) appQueryResult {
|
||||
appKey := v2.AppKey{
|
||||
Organization: config.Metadata.Organization,
|
||||
Name: config.Metadata.Name,
|
||||
Version: config.Metadata.AppVersion,
|
||||
}
|
||||
|
||||
app, err := p.client.ShowApp(ctx, appKey, region)
|
||||
if err != nil {
|
||||
if isNotFoundError(err) {
|
||||
return appQueryResult{app: nil, err: nil}
|
||||
}
|
||||
return appQueryResult{app: nil, err: err}
|
||||
}
|
||||
|
||||
return appQueryResult{
|
||||
app: &AppDeletion{
|
||||
Name: app.Key.Name,
|
||||
Version: app.Key.Version,
|
||||
Organization: app.Key.Organization,
|
||||
Region: region,
|
||||
},
|
||||
err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// findInstancesToDelete finds all instances that match the config
|
||||
func (p *EdgeConnectPlanner) findInstancesToDelete(ctx context.Context, config *config.EdgeConnectConfig, region string) instancesQueryResult {
|
||||
var allInstances []InstanceDeletion
|
||||
|
||||
// Query instances for each infra template
|
||||
for _, infra := range config.Spec.InfraTemplate {
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: config.Metadata.Organization,
|
||||
Name: generateInstanceName(config.Metadata.Name, config.Metadata.AppVersion),
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: infra.CloudletOrg,
|
||||
Name: infra.CloudletName,
|
||||
},
|
||||
}
|
||||
appKey := v2.AppKey{Name: config.Metadata.Name}
|
||||
|
||||
instances, err := p.client.ShowAppInstances(ctx, instanceKey, appKey, infra.Region)
|
||||
if err != nil {
|
||||
// If it's a not found error, just continue
|
||||
if isNotFoundError(err) {
|
||||
continue
|
||||
}
|
||||
return instancesQueryResult{instances: nil, err: err}
|
||||
}
|
||||
|
||||
// Add found instances to the list
|
||||
for _, inst := range instances {
|
||||
allInstances = append(allInstances, InstanceDeletion{
|
||||
Name: inst.Key.Name,
|
||||
Organization: inst.Key.Organization,
|
||||
Region: infra.Region,
|
||||
CloudletOrg: inst.Key.CloudletKey.Organization,
|
||||
CloudletName: inst.Key.CloudletKey.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return instancesQueryResult{
|
||||
instances: allInstances,
|
||||
err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// calculatePlanMetadata calculates the total actions and estimated duration
|
||||
func (p *EdgeConnectPlanner) calculatePlanMetadata(plan *DeletionPlan) {
|
||||
totalActions := 0
|
||||
|
||||
if plan.AppToDelete != nil {
|
||||
totalActions++
|
||||
}
|
||||
|
||||
totalActions += len(plan.InstancesToDelete)
|
||||
|
||||
plan.TotalActions = totalActions
|
||||
|
||||
// Estimate duration: ~5 seconds per instance, ~3 seconds for app
|
||||
estimatedSeconds := len(plan.InstancesToDelete) * 5
|
||||
if plan.AppToDelete != nil {
|
||||
estimatedSeconds += 3
|
||||
}
|
||||
plan.EstimatedDuration = time.Duration(estimatedSeconds) * time.Second
|
||||
}
|
||||
|
||||
// generateInstanceName creates an instance name from app name and version
|
||||
func generateInstanceName(appName, appVersion string) string {
|
||||
return fmt.Sprintf("%s-%s-instance", appName, appVersion)
|
||||
}
|
||||
|
||||
// isNotFoundError checks if an error is a 404 not found error
|
||||
func isNotFoundError(err error) bool {
|
||||
if apiErr, ok := err.(*v2.APIError); ok {
|
||||
return apiErr.StatusCode == 404
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PlanResult represents the result of a deletion planning operation
|
||||
type PlanResult struct {
|
||||
// Plan is the generated deletion plan
|
||||
Plan *DeletionPlan
|
||||
|
||||
// Error if planning failed
|
||||
Error error
|
||||
|
||||
// Warnings encountered during planning
|
||||
Warnings []string
|
||||
}
|
||||
219
internal/delete/v2/planner_test.go
Normal file
219
internal/delete/v2/planner_test.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// ABOUTME: Tests for EdgeConnect deletion planner with mock scenarios
|
||||
// ABOUTME: Tests deletion planning logic and resource discovery
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/internal/config"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockEdgeConnectClient is a mock implementation of the EdgeConnect client
|
||||
type MockEdgeConnectClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowApp(ctx context.Context, appKey v2.AppKey, region string) (v2.App, error) {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return v2.App{}, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(v2.App), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) ShowAppInstances(ctx context.Context, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string) ([]v2.AppInstance, error) {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]v2.AppInstance), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) DeleteApp(ctx context.Context, appKey v2.AppKey, region string) error {
|
||||
args := m.Called(ctx, appKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockEdgeConnectClient) DeleteAppInstance(ctx context.Context, instanceKey v2.AppInstanceKey, region string) error {
|
||||
args := m.Called(ctx, instanceKey, region)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func createTestConfig(t *testing.T) *config.EdgeConnectConfig {
|
||||
// Create temporary manifest file
|
||||
tempDir := t.TempDir()
|
||||
manifestFile := filepath.Join(tempDir, "test-manifest.yaml")
|
||||
manifestContent := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\n"
|
||||
err := os.WriteFile(manifestFile, []byte(manifestContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &config.EdgeConnectConfig{
|
||||
Kind: "edgeconnect-deployment",
|
||||
Metadata: config.Metadata{
|
||||
Name: "test-app",
|
||||
AppVersion: "1.0.0",
|
||||
Organization: "testorg",
|
||||
},
|
||||
Spec: config.Spec{
|
||||
K8sApp: &config.K8sApp{
|
||||
ManifestFile: manifestFile,
|
||||
},
|
||||
InfraTemplate: []config.InfraTemplate{
|
||||
{
|
||||
Region: "US",
|
||||
CloudletOrg: "TestCloudletOrg",
|
||||
CloudletName: "TestCloudlet",
|
||||
FlavorName: "small",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPlanner(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
|
||||
assert.NotNil(t, planner)
|
||||
}
|
||||
|
||||
func TestPlanDeletion_WithExistingResources(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
testConfig := createTestConfig(t)
|
||||
|
||||
// Mock existing app
|
||||
existingApp := v2.App{
|
||||
Key: v2.AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Deployment: "kubernetes",
|
||||
}
|
||||
|
||||
// Mock existing instances
|
||||
existingInstances := []v2.AppInstance{
|
||||
{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app-1.0.0-instance",
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: "TestCloudletOrg",
|
||||
Name: "TestCloudlet",
|
||||
},
|
||||
},
|
||||
AppKey: v2.AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(existingApp, nil)
|
||||
|
||||
mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(existingInstances, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := planner.Plan(ctx, testConfig)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Plan)
|
||||
|
||||
plan := result.Plan
|
||||
assert.Equal(t, "test-app", plan.ConfigName)
|
||||
assert.NotNil(t, plan.AppToDelete)
|
||||
assert.Equal(t, "test-app", plan.AppToDelete.Name)
|
||||
assert.Equal(t, "1.0.0", plan.AppToDelete.Version)
|
||||
assert.Equal(t, "testorg", plan.AppToDelete.Organization)
|
||||
|
||||
require.Len(t, plan.InstancesToDelete, 1)
|
||||
assert.Equal(t, "test-app-1.0.0-instance", plan.InstancesToDelete[0].Name)
|
||||
assert.Equal(t, "testorg", plan.InstancesToDelete[0].Organization)
|
||||
|
||||
assert.Equal(t, 2, plan.TotalActions) // 1 app + 1 instance
|
||||
assert.False(t, plan.IsEmpty())
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestPlanDeletion_NoResourcesExist(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
testConfig := createTestConfig(t)
|
||||
|
||||
// Mock API calls to return "not found" errors
|
||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(v2.App{}, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
||||
|
||||
mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return([]v2.AppInstance{}, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := planner.Plan(ctx, testConfig)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Plan)
|
||||
|
||||
plan := result.Plan
|
||||
assert.Equal(t, "test-app", plan.ConfigName)
|
||||
assert.Nil(t, plan.AppToDelete)
|
||||
assert.Len(t, plan.InstancesToDelete, 0)
|
||||
assert.Equal(t, 0, plan.TotalActions)
|
||||
assert.True(t, plan.IsEmpty())
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestPlanDeletion_OnlyInstancesExist(t *testing.T) {
|
||||
mockClient := &MockEdgeConnectClient{}
|
||||
planner := NewPlanner(mockClient)
|
||||
testConfig := createTestConfig(t)
|
||||
|
||||
// Mock existing instances but no app
|
||||
existingInstances := []v2.AppInstance{
|
||||
{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "test-app-1.0.0-instance",
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: "TestCloudletOrg",
|
||||
Name: "TestCloudlet",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockClient.On("ShowApp", mock.Anything, mock.AnythingOfType("v2.AppKey"), "US").
|
||||
Return(v2.App{}, &v2.APIError{StatusCode: 404, Messages: []string{"App not found"}})
|
||||
|
||||
mockClient.On("ShowAppInstances", mock.Anything, mock.AnythingOfType("v2.AppInstanceKey"), "US").
|
||||
Return(existingInstances, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := planner.Plan(ctx, testConfig)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Plan)
|
||||
|
||||
plan := result.Plan
|
||||
assert.Nil(t, plan.AppToDelete)
|
||||
assert.Len(t, plan.InstancesToDelete, 1)
|
||||
assert.Equal(t, 1, plan.TotalActions)
|
||||
assert.False(t, plan.IsEmpty())
|
||||
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
157
internal/delete/v2/types.go
Normal file
157
internal/delete/v2/types.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// ABOUTME: Deletion planning types for EdgeConnect delete command
|
||||
// ABOUTME: Defines structures for deletion plans and deletion results
|
||||
package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeletionPlan represents the complete deletion plan for a configuration
|
||||
type DeletionPlan struct {
|
||||
// ConfigName is the name from metadata
|
||||
ConfigName string
|
||||
|
||||
// AppToDelete defines the app that will be deleted (nil if app doesn't exist)
|
||||
AppToDelete *AppDeletion
|
||||
|
||||
// InstancesToDelete defines the instances that will be deleted
|
||||
InstancesToDelete []InstanceDeletion
|
||||
|
||||
// Summary provides a human-readable summary of the plan
|
||||
Summary string
|
||||
|
||||
// TotalActions is the count of all actions that will be performed
|
||||
TotalActions int
|
||||
|
||||
// EstimatedDuration is the estimated time to complete the deletion
|
||||
EstimatedDuration time.Duration
|
||||
|
||||
// CreatedAt timestamp when the plan was created
|
||||
CreatedAt time.Time
|
||||
|
||||
// DryRun indicates if this is a dry-run plan
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// AppDeletion represents an application to be deleted
|
||||
type AppDeletion struct {
|
||||
// Name of the application
|
||||
Name string
|
||||
|
||||
// Version of the application
|
||||
Version string
|
||||
|
||||
// Organization that owns the app
|
||||
Organization string
|
||||
|
||||
// Region where the app is deployed
|
||||
Region string
|
||||
}
|
||||
|
||||
// InstanceDeletion represents an application instance to be deleted
|
||||
type InstanceDeletion struct {
|
||||
// Name of the instance
|
||||
Name string
|
||||
|
||||
// Organization that owns the instance
|
||||
Organization string
|
||||
|
||||
// Region where the instance is deployed
|
||||
Region string
|
||||
|
||||
// CloudletOrg that hosts the cloudlet
|
||||
CloudletOrg string
|
||||
|
||||
// CloudletName where the instance is running
|
||||
CloudletName string
|
||||
}
|
||||
|
||||
// DeletionResult represents the result of a deletion operation
|
||||
type DeletionResult struct {
|
||||
// Plan that was executed
|
||||
Plan *DeletionPlan
|
||||
|
||||
// Success indicates if the deletion was successful
|
||||
Success bool
|
||||
|
||||
// CompletedActions lists actions that were successfully completed
|
||||
CompletedActions []DeletionActionResult
|
||||
|
||||
// FailedActions lists actions that failed
|
||||
FailedActions []DeletionActionResult
|
||||
|
||||
// Error that caused the deletion to fail (if any)
|
||||
Error error
|
||||
|
||||
// Duration taken to execute the plan
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// DeletionActionResult represents the result of executing a single deletion action
|
||||
type DeletionActionResult struct {
|
||||
// Type of resource that was deleted ("app" or "instance")
|
||||
Type string
|
||||
|
||||
// Target describes what was being deleted
|
||||
Target string
|
||||
|
||||
// Success indicates if the action succeeded
|
||||
Success bool
|
||||
|
||||
// Error if the action failed
|
||||
Error error
|
||||
|
||||
// Duration taken to complete the action
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the deletion plan has no actions to perform
|
||||
func (dp *DeletionPlan) IsEmpty() bool {
|
||||
return dp.AppToDelete == nil && len(dp.InstancesToDelete) == 0
|
||||
}
|
||||
|
||||
// GenerateSummary creates a human-readable summary of the deletion plan
|
||||
func (dp *DeletionPlan) GenerateSummary() string {
|
||||
if dp.IsEmpty() {
|
||||
return "No resources found to delete"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Deletion plan for '%s':\n", dp.ConfigName))
|
||||
|
||||
// Instance actions
|
||||
if len(dp.InstancesToDelete) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- DELETE %d instance(s)\n", len(dp.InstancesToDelete)))
|
||||
cloudletSet := make(map[string]bool)
|
||||
for _, inst := range dp.InstancesToDelete {
|
||||
key := fmt.Sprintf("%s:%s", inst.CloudletOrg, inst.CloudletName)
|
||||
cloudletSet[key] = true
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Across %d cloudlet(s)\n", len(cloudletSet)))
|
||||
}
|
||||
|
||||
// App action
|
||||
if dp.AppToDelete != nil {
|
||||
sb.WriteString(fmt.Sprintf("- DELETE application '%s' version %s\n",
|
||||
dp.AppToDelete.Name, dp.AppToDelete.Version))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("Estimated duration: %s", dp.EstimatedDuration.String()))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Validate checks if the deletion plan is valid
|
||||
func (dp *DeletionPlan) Validate() error {
|
||||
if dp.ConfigName == "" {
|
||||
return fmt.Errorf("deletion plan must have a config name")
|
||||
}
|
||||
|
||||
if dp.IsEmpty() {
|
||||
return fmt.Errorf("deletion plan has no resources to delete")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
95
internal/delete/v2/types_test.go
Normal file
95
internal/delete/v2/types_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package v2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeletionPlan_IsEmpty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
plan *DeletionPlan
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "empty plan with no resources",
|
||||
plan: &DeletionPlan{
|
||||
ConfigName: "test-config",
|
||||
AppToDelete: nil,
|
||||
InstancesToDelete: []InstanceDeletion{},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "plan with app deletion",
|
||||
plan: &DeletionPlan{
|
||||
ConfigName: "test-config",
|
||||
AppToDelete: &AppDeletion{
|
||||
Name: "test-app",
|
||||
Organization: "test-org",
|
||||
Version: "1.0",
|
||||
Region: "US",
|
||||
},
|
||||
InstancesToDelete: []InstanceDeletion{},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "plan with instance deletion",
|
||||
plan: &DeletionPlan{
|
||||
ConfigName: "test-config",
|
||||
AppToDelete: nil,
|
||||
InstancesToDelete: []InstanceDeletion{
|
||||
{
|
||||
Name: "test-instance",
|
||||
Organization: "test-org",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.plan.IsEmpty()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletionPlan_GenerateSummary(t *testing.T) {
|
||||
plan := &DeletionPlan{
|
||||
ConfigName: "test-config",
|
||||
AppToDelete: &AppDeletion{
|
||||
Name: "test-app",
|
||||
Organization: "test-org",
|
||||
Version: "1.0",
|
||||
Region: "US",
|
||||
},
|
||||
InstancesToDelete: []InstanceDeletion{
|
||||
{
|
||||
Name: "test-instance-1",
|
||||
Organization: "test-org",
|
||||
CloudletName: "cloudlet-1",
|
||||
CloudletOrg: "cloudlet-org",
|
||||
},
|
||||
{
|
||||
Name: "test-instance-2",
|
||||
Organization: "test-org",
|
||||
CloudletName: "cloudlet-2",
|
||||
CloudletOrg: "cloudlet-org",
|
||||
},
|
||||
},
|
||||
TotalActions: 3,
|
||||
EstimatedDuration: 30 * time.Second,
|
||||
}
|
||||
|
||||
summary := plan.GenerateSummary()
|
||||
|
||||
assert.Contains(t, summary, "test-config")
|
||||
assert.Contains(t, summary, "DELETE application 'test-app'")
|
||||
assert.Contains(t, summary, "DELETE 2 instance(s)")
|
||||
}
|
||||
2
main.go
2
main.go
|
|
@ -1,6 +1,6 @@
|
|||
package main
|
||||
|
||||
import "edp.buildth.ing/DevFW-CICD/edge-connect-client/cmd"
|
||||
import "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
|
|
|
|||
BIN
public.gpg
Normal file
BIN
public.gpg
Normal file
Binary file not shown.
|
|
@ -16,18 +16,18 @@ A comprehensive Go SDK for the EdgeXR Master Controller API, providing typed int
|
|||
### Installation
|
||||
|
||||
```go
|
||||
import "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
import v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
```go
|
||||
// Username/password (recommended)
|
||||
client := client.NewClientWithCredentials(baseURL, username, password)
|
||||
client := v2.NewClientWithCredentials(baseURL, username, password)
|
||||
|
||||
// Static Bearer token
|
||||
client := client.NewClient(baseURL,
|
||||
client.WithAuthProvider(client.NewStaticTokenProvider(token)))
|
||||
client := v2.NewClient(baseURL,
|
||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)))
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
|
@ -36,10 +36,10 @@ client := client.NewClient(baseURL,
|
|||
ctx := context.Background()
|
||||
|
||||
// Create an application
|
||||
app := &client.NewAppInput{
|
||||
app := &v2.NewAppInput{
|
||||
Region: "us-west",
|
||||
App: client.App{
|
||||
Key: client.AppKey{
|
||||
App: v2.App{
|
||||
Key: v2.AppKey{
|
||||
Organization: "myorg",
|
||||
Name: "my-app",
|
||||
Version: "1.0.0",
|
||||
|
|
@ -49,28 +49,28 @@ app := &client.NewAppInput{
|
|||
},
|
||||
}
|
||||
|
||||
if err := client.CreateApp(ctx, app); err != nil {
|
||||
if err := v2.CreateApp(ctx, app); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Deploy an application instance
|
||||
instance := &client.NewAppInstanceInput{
|
||||
instance := &v2.NewAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: client.AppInstance{
|
||||
Key: client.AppInstanceKey{
|
||||
AppInst: v2.AppInstance{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: "myorg",
|
||||
Name: "my-instance",
|
||||
CloudletKey: client.CloudletKey{
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: "cloudlet-provider",
|
||||
Name: "edge-cloudlet",
|
||||
},
|
||||
},
|
||||
AppKey: app.App.Key,
|
||||
Flavor: client.Flavor{Name: "m4.small"},
|
||||
Flavor: v2.Flavor{Name: "m4.small"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := client.CreateAppInstance(ctx, instance); err != nil {
|
||||
if err := v2.CreateAppInstance(ctx, instance); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
|
@ -101,22 +101,22 @@ if err := client.CreateAppInstance(ctx, instance); err != nil {
|
|||
## Configuration Options
|
||||
|
||||
```go
|
||||
client := client.NewClient(baseURL,
|
||||
client := v2.NewClient(baseURL,
|
||||
// Custom HTTP client with timeout
|
||||
client.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
|
||||
// Authentication provider
|
||||
client.WithAuthProvider(client.NewStaticTokenProvider(token)),
|
||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)),
|
||||
|
||||
// Retry configuration
|
||||
client.WithRetryOptions(client.RetryOptions{
|
||||
v2.WithRetryOptions(v2.RetryOptions{
|
||||
MaxRetries: 5,
|
||||
InitialDelay: 1 * time.Second,
|
||||
MaxDelay: 30 * time.Second,
|
||||
}),
|
||||
|
||||
// Request logging
|
||||
client.WithLogger(log.Default()),
|
||||
v2.WithLogger(log.Default()),
|
||||
)
|
||||
```
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ EDGEXR_USERNAME=user EDGEXR_PASSWORD=pass go run main.go
|
|||
Uses the existing `/api/v1/login` endpoint with automatic token caching:
|
||||
|
||||
```go
|
||||
client := client.NewClientWithCredentials(baseURL, username, password)
|
||||
client := v2.NewClientWithCredentials(baseURL, username, password)
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
|
@ -154,23 +154,23 @@ client := client.NewClientWithCredentials(baseURL, username, password)
|
|||
For pre-obtained tokens:
|
||||
|
||||
```go
|
||||
client := client.NewClient(baseURL,
|
||||
client.WithAuthProvider(client.NewStaticTokenProvider(token)))
|
||||
client := v2.NewClient(baseURL,
|
||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)))
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```go
|
||||
app, err := client.ShowApp(ctx, appKey, region)
|
||||
app, err := v2.ShowApp(ctx, appKey, region)
|
||||
if err != nil {
|
||||
// Check for specific error types
|
||||
if errors.Is(err, client.ErrResourceNotFound) {
|
||||
if errors.Is(err, v2.ErrResourceNotFound) {
|
||||
fmt.Println("App not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for API errors
|
||||
var apiErr *client.APIError
|
||||
var apiErr *v2.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
fmt.Printf("API Error %d: %s\n", apiErr.StatusCode, apiErr.Messages[0])
|
||||
return
|
||||
|
|
@ -213,13 +213,13 @@ The SDK provides a drop-in replacement with enhanced features:
|
|||
|
||||
```go
|
||||
// Old approach
|
||||
oldClient := &client.EdgeConnect{
|
||||
oldClient := &v2.EdgeConnect{
|
||||
BaseURL: baseURL,
|
||||
Credentials: client.Credentials{Username: user, Password: pass},
|
||||
Credentials: v2.Credentials{Username: user, Password: pass},
|
||||
}
|
||||
|
||||
// New SDK approach
|
||||
newClient := client.NewClientWithCredentials(baseURL, user, pass)
|
||||
newClient := v2.NewClientWithCredentials(baseURL, user, pass)
|
||||
|
||||
// Same method calls, enhanced reliability
|
||||
err := newClient.CreateApp(ctx, input)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http"
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
|
||||
)
|
||||
|
||||
// CreateAppInstance creates a new application instance in the specified region
|
||||
// Maps to POST /auth/ctrl/CreateAppInst
|
||||
func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInput) error {
|
||||
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/CreateAppInst"
|
||||
|
||||
|
|
@ -22,12 +23,20 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp
|
|||
if err != nil {
|
||||
return fmt.Errorf("CreateAppInstance failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "CreateAppInstance")
|
||||
}
|
||||
|
||||
// Parse streaming JSON response
|
||||
var appInstances []AppInstance
|
||||
if err := c.parseStreamingAppInstanceResponse(resp, &appInstances); err != nil {
|
||||
return fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
c.logf("CreateAppInstance: %s/%s created successfully",
|
||||
input.AppInst.Key.Organization, input.AppInst.Key.Name)
|
||||
|
||||
|
|
@ -36,12 +45,12 @@ func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInp
|
|||
|
||||
// ShowAppInstance retrieves a single application instance by key and region
|
||||
// Maps to POST /auth/ctrl/ShowAppInst
|
||||
func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) (AppInstance, error) {
|
||||
func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) (AppInstance, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
|
||||
|
||||
filter := AppInstanceFilter{
|
||||
AppInstance: AppInstance{Key: appInstKey},
|
||||
AppInstance: AppInstance{AppKey: appKey, Key: appInstKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +58,9 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey,
|
|||
if err != nil {
|
||||
return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return AppInstance{}, fmt.Errorf("app instance %s/%s: %w",
|
||||
|
|
@ -76,12 +87,12 @@ func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey,
|
|||
|
||||
// ShowAppInstances retrieves all application instances matching the filter criteria
|
||||
// Maps to POST /auth/ctrl/ShowAppInst
|
||||
func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, region string) ([]AppInstance, error) {
|
||||
func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) ([]AppInstance, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
|
||||
|
||||
filter := AppInstanceFilter{
|
||||
AppInstance: AppInstance{Key: appInstKey},
|
||||
AppInstance: AppInstance{Key: appInstKey, AppKey: appKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +100,9 @@ func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return nil, c.handleErrorResponse(resp, "ShowAppInstances")
|
||||
|
|
@ -118,7 +131,9 @@ func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstance
|
|||
if err != nil {
|
||||
return fmt.Errorf("UpdateAppInstance failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "UpdateAppInstance")
|
||||
|
|
@ -145,7 +160,9 @@ func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceK
|
|||
if err != nil {
|
||||
return fmt.Errorf("RefreshAppInstance failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "RefreshAppInstance")
|
||||
|
|
@ -172,7 +189,9 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe
|
|||
if err != nil {
|
||||
return fmt.Errorf("DeleteAppInstance failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// 404 is acceptable for delete operations (already deleted)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
|
|
@ -187,14 +206,45 @@ func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKe
|
|||
|
||||
// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances
|
||||
func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result interface{}) error {
|
||||
var responses []Response[AppInstance]
|
||||
var appInstances []AppInstance
|
||||
var messages []string
|
||||
var hasError bool
|
||||
var errorCode int
|
||||
var errorMessage string
|
||||
|
||||
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
|
||||
// On permission denied, Edge API returns just an empty array []!
|
||||
if len(line) == 0 || line[0] == '[' {
|
||||
return fmt.Errorf("%w", ErrFaultyResponsePerhaps403)
|
||||
}
|
||||
// Try parsing as ResultResponse first (error format)
|
||||
var resultResp ResultResponse
|
||||
if err := json.Unmarshal(line, &resultResp); err == nil && resultResp.Result.Message != "" {
|
||||
if resultResp.IsError() {
|
||||
hasError = true
|
||||
errorCode = resultResp.GetCode()
|
||||
errorMessage = resultResp.GetMessage()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try parsing as Response[AppInstance]
|
||||
var response Response[AppInstance]
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
responses = append(responses, response)
|
||||
|
||||
if response.HasData() {
|
||||
appInstances = append(appInstances, response.Data)
|
||||
}
|
||||
if response.IsMessage() {
|
||||
msg := response.Data.GetMessage()
|
||||
messages = append(messages, msg)
|
||||
// Check for error indicators in messages
|
||||
if msg == "CreateError" || msg == "UpdateError" || msg == "DeleteError" {
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
|
@ -202,25 +252,20 @@ func (c *Client) parseStreamingAppInstanceResponse(resp *http.Response, result i
|
|||
return parseErr
|
||||
}
|
||||
|
||||
// Extract data from responses
|
||||
var appInstances []AppInstance
|
||||
var messages []string
|
||||
|
||||
for _, response := range responses {
|
||||
if response.HasData() {
|
||||
appInstances = append(appInstances, response.Data)
|
||||
}
|
||||
if response.IsMessage() {
|
||||
messages = append(messages, response.Data.GetMessage())
|
||||
}
|
||||
}
|
||||
|
||||
// If we have error messages, return them
|
||||
if len(messages) > 0 {
|
||||
return &APIError{
|
||||
// If we detected an error, return it
|
||||
if hasError {
|
||||
apiErr := &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Messages: messages,
|
||||
}
|
||||
if errorCode > 0 {
|
||||
apiErr.StatusCode = errorCode
|
||||
apiErr.Code = fmt.Sprintf("%d", errorCode)
|
||||
}
|
||||
if errorMessage != "" {
|
||||
apiErr.Messages = append([]string{errorMessage}, apiErr.Messages...)
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
// Set result based on type
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ func TestCreateAppInstance(t *testing.T) {
|
|||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "successful creation",
|
||||
|
|
@ -63,6 +64,57 @@ func TestCreateAppInstance(t *testing.T) {
|
|||
mockResponse: `{"message": "organization is required"}`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "HTTP 200 with CreateError message",
|
||||
input: &NewAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
Flavor: Flavor{Name: "m4.small"},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"data":{"message":"Creating"}}
|
||||
{"data":{"message":"a service has been configured"}}
|
||||
{"data":{"message":"CreateError"}}
|
||||
{"data":{"message":"Deleting AppInst due to failure"}}
|
||||
{"data":{"message":"Deleted AppInst successfully"}}`,
|
||||
expectError: true,
|
||||
errorContains: "CreateError",
|
||||
},
|
||||
{
|
||||
name: "HTTP 200 with result error code",
|
||||
input: &NewAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
Flavor: Flavor{Name: "m4.small"},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"data":{"message":"Creating"}}
|
||||
{"data":{"message":"a service has been configured"}}
|
||||
{"data":{"message":"CreateError"}}
|
||||
{"data":{"message":"Deleting AppInst due to failure"}}
|
||||
{"data":{"message":"Deleted AppInst successfully"}}
|
||||
{"result":{"message":"Encountered failures: Create App Inst failed: deployments.apps is forbidden: User \"system:serviceaccount:edgexr:crm-telekomop-munich\" cannot create resource \"deployments\" in API group \"apps\" in the namespace \"gitea\"","code":400}}`,
|
||||
expectError: true,
|
||||
errorContains: "deployments.apps is forbidden",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -74,7 +126,7 @@ func TestCreateAppInstance(t *testing.T) {
|
|||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -91,6 +143,9 @@ func TestCreateAppInstance(t *testing.T) {
|
|||
// Verify results
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
if tt.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
|
@ -101,6 +156,7 @@ func TestCreateAppInstance(t *testing.T) {
|
|||
func TestShowAppInstance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appKey AppKey
|
||||
appInstKey AppInstanceKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
|
|
@ -118,6 +174,7 @@ func TestShowAppInstance(t *testing.T) {
|
|||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
appKey: AppKey{Name: "test-app-id"},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}}
|
||||
|
|
@ -135,6 +192,7 @@ func TestShowAppInstance(t *testing.T) {
|
|||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
appKey: AppKey{Name: "test-app-id"},
|
||||
region: "us-west",
|
||||
mockStatusCode: 404,
|
||||
mockResponse: "",
|
||||
|
|
@ -152,7 +210,7 @@ func TestShowAppInstance(t *testing.T) {
|
|||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
|
@ -164,7 +222,7 @@ func TestShowAppInstance(t *testing.T) {
|
|||
|
||||
// Execute test
|
||||
ctx := context.Background()
|
||||
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.region)
|
||||
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region)
|
||||
|
||||
// Verify results
|
||||
if tt.expectError {
|
||||
|
|
@ -199,14 +257,14 @@ func TestShowAppInstances(t *testing.T) {
|
|||
{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}}
|
||||
`
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(response))
|
||||
_, _ = w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, "us-west")
|
||||
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, appInstances, 2)
|
||||
|
|
@ -306,7 +364,7 @@ func TestUpdateAppInstance(t *testing.T) {
|
|||
assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http"
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrResourceNotFound indicates the requested resource was not found
|
||||
ErrResourceNotFound = fmt.Errorf("resource not found")
|
||||
ErrResourceNotFound = fmt.Errorf("resource not found")
|
||||
ErrFaultyResponsePerhaps403 = fmt.Errorf("faulty response from API, may indicate permission denied")
|
||||
)
|
||||
|
||||
// CreateApp creates a new application in the specified region
|
||||
|
|
@ -28,7 +29,9 @@ func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("CreateApp failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "CreateApp")
|
||||
|
|
@ -55,7 +58,9 @@ func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App
|
|||
if err != nil {
|
||||
return App{}, fmt.Errorf("ShowApp failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
|
||||
|
|
@ -95,7 +100,9 @@ func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("ShowApps failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return nil, c.handleErrorResponse(resp, "ShowApps")
|
||||
|
|
@ -124,7 +131,9 @@ func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("UpdateApp failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "UpdateApp")
|
||||
|
|
@ -151,7 +160,9 @@ func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) er
|
|||
if err != nil {
|
||||
return fmt.Errorf("DeleteApp failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// 404 is acceptable for delete operations (already deleted)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
|
|
@ -169,6 +180,10 @@ func (c *Client) parseStreamingResponse(resp *http.Response, result interface{})
|
|||
var responses []Response[App]
|
||||
|
||||
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
|
||||
// On permission denied, Edge API returns just an empty array []!
|
||||
if len(line) == 0 || line[0] == '[' {
|
||||
return fmt.Errorf("%w", ErrFaultyResponsePerhaps403)
|
||||
}
|
||||
var response Response[App]
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
return err
|
||||
|
|
@ -238,7 +253,9 @@ func (c *Client) handleErrorResponse(resp *http.Response, operation string) erro
|
|||
bodyBytes := []byte{}
|
||||
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
bodyBytes, _ = io.ReadAll(resp.Body)
|
||||
messages = append(messages, string(bodyBytes))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ func TestCreateApp(t *testing.T) {
|
|||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -139,7 +139,7 @@ func TestShowApp(t *testing.T) {
|
|||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
|
@ -186,7 +186,7 @@ func TestShowApps(t *testing.T) {
|
|||
{"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}}
|
||||
`
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(response))
|
||||
_, _ = w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -277,7 +277,7 @@ func TestUpdateApp(t *testing.T) {
|
|||
assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -407,13 +407,3 @@ func TestAPIError(t *testing.T) {
|
|||
assert.Equal(t, 400, err.StatusCode)
|
||||
assert.Len(t, err.Messages, 2)
|
||||
}
|
||||
|
||||
// Helper function to create a test server that handles streaming JSON responses
|
||||
func createStreamingJSONServer(responses []string, statusCode int) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(statusCode)
|
||||
for _, response := range responses {
|
||||
w.Write([]byte(response + "\n"))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,7 +138,9 @@ func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, e
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// Read response body - same as existing implementation
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ func TestUsernamePasswordProvider_Success(t *testing.T) {
|
|||
// Return token
|
||||
response := map[string]string{"token": "dynamic-token-456"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ func TestUsernamePasswordProvider_LoginFailure(t *testing.T) {
|
|||
// Mock login server that returns error
|
||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Invalid credentials"))
|
||||
_, _ = w.Write([]byte("Invalid credentials"))
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ func TestUsernamePasswordProvider_TokenCaching(t *testing.T) {
|
|||
callCount++
|
||||
response := map[string]string{"token": "cached-token-789"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) {
|
|||
callCount++
|
||||
response := map[string]string{"token": "refreshed-token-999"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) {
|
|||
callCount++
|
||||
response := map[string]string{"token": "new-token-after-invalidation"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
|
|
@ -185,7 +185,7 @@ func TestUsernamePasswordProvider_BadJSONResponse(t *testing.T) {
|
|||
// Mock server returning invalid JSON
|
||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte("invalid json response"))
|
||||
_, _ = w.Write([]byte("invalid json response"))
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/internal/http"
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
|
||||
)
|
||||
|
||||
// CreateCloudlet creates a new cloudlet in the specified region
|
||||
|
|
@ -22,7 +22,9 @@ func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) er
|
|||
if err != nil {
|
||||
return fmt.Errorf("CreateCloudlet failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "CreateCloudlet")
|
||||
|
|
@ -49,7 +51,9 @@ func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, regi
|
|||
if err != nil {
|
||||
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
|
||||
|
|
@ -89,7 +93,9 @@ func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, reg
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("ShowCloudlets failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return nil, c.handleErrorResponse(resp, "ShowCloudlets")
|
||||
|
|
@ -123,7 +129,9 @@ func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, re
|
|||
if err != nil {
|
||||
return fmt.Errorf("DeleteCloudlet failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// 404 is acceptable for delete operations (already deleted)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
|
|
@ -151,7 +159,9 @@ func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKe
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("GetCloudletManifest failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w",
|
||||
|
|
@ -189,7 +199,9 @@ func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey Cloud
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w",
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ func TestCreateCloudlet(t *testing.T) {
|
|||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ func TestShowCloudlet(t *testing.T) {
|
|||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
|
@ -187,7 +187,7 @@ func TestShowCloudlets(t *testing.T) {
|
|||
{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}}
|
||||
`
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(response))
|
||||
_, _ = w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -312,7 +312,7 @@ func TestGetCloudletManifest(t *testing.T) {
|
|||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
|
@ -380,7 +380,7 @@ func TestGetCloudletResourceUsage(t *testing.T) {
|
|||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
w.Write([]byte(tt.mockResponse))
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
|
|
|||
|
|
@ -60,74 +60,74 @@ const (
|
|||
|
||||
// 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"
|
||||
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"
|
||||
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
|
||||
|
|
@ -271,6 +271,26 @@ func (res *Response[T]) IsMessage() bool {
|
|||
return res.Data.GetMessage() != ""
|
||||
}
|
||||
|
||||
// ResultResponse represents an API result with error code
|
||||
type ResultResponse struct {
|
||||
Result struct {
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
func (r *ResultResponse) IsError() bool {
|
||||
return r.Result.Code >= 400
|
||||
}
|
||||
|
||||
func (r *ResultResponse) GetMessage() string {
|
||||
return r.Result.Message
|
||||
}
|
||||
|
||||
func (r *ResultResponse) GetCode() int {
|
||||
return r.Result.Code
|
||||
}
|
||||
|
||||
// Responses wraps multiple API responses with metadata
|
||||
type Responses[T Message] struct {
|
||||
Responses []Response[T] `json:"responses,omitempty"`
|
||||
|
|
|
|||
293
sdk/edgeconnect/v2/appinstance.go
Normal file
293
sdk/edgeconnect/v2/appinstance.go
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
// ABOUTME: Application instance lifecycle management APIs for EdgeXR Master Controller
|
||||
// ABOUTME: Provides typed methods for creating, querying, and deleting application instances
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CreateAppInstance creates a new application instance in the specified region
|
||||
// Maps to POST /auth/ctrl/CreateAppInst
|
||||
func (c *Client) CreateAppInstance(ctx context.Context, input *NewAppInstanceInput) error {
|
||||
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/CreateAppInst"
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateAppInstance failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "CreateAppInstance")
|
||||
}
|
||||
|
||||
// Parse streaming JSON response
|
||||
if _, err = parseStreamingResponse[AppInstance](resp); err != nil {
|
||||
return fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
c.logf("CreateAppInstance: %s/%s created successfully",
|
||||
input.AppInst.Key.Organization, input.AppInst.Key.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShowAppInstance retrieves a single application instance by key and region
|
||||
// Maps to POST /auth/ctrl/ShowAppInst
|
||||
func (c *Client) ShowAppInstance(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) (AppInstance, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
|
||||
|
||||
filter := AppInstanceFilter{
|
||||
AppInstance: AppInstance{Key: appInstKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return AppInstance{}, fmt.Errorf("ShowAppInstance failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return AppInstance{}, fmt.Errorf("app instance %s/%s: %w",
|
||||
appInstKey.Organization, appInstKey.Name, ErrResourceNotFound)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return AppInstance{}, c.handleErrorResponse(resp, "ShowAppInstance")
|
||||
}
|
||||
|
||||
// Parse streaming JSON response
|
||||
var appInstances []AppInstance
|
||||
if appInstances, err = parseStreamingResponse[AppInstance](resp); err != nil {
|
||||
return AppInstance{}, fmt.Errorf("ShowAppInstance failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(appInstances) == 0 {
|
||||
return AppInstance{}, fmt.Errorf("app instance %s/%s in region %s: %w",
|
||||
appInstKey.Organization, appInstKey.Name, region, ErrResourceNotFound)
|
||||
}
|
||||
|
||||
return appInstances[0], nil
|
||||
}
|
||||
|
||||
// ShowAppInstances retrieves all application instances matching the filter criteria
|
||||
// Maps to POST /auth/ctrl/ShowAppInst
|
||||
func (c *Client) ShowAppInstances(ctx context.Context, appInstKey AppInstanceKey, appKey AppKey, region string) ([]AppInstance, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowAppInst"
|
||||
|
||||
filter := AppInstanceFilter{
|
||||
AppInstance: AppInstance{Key: appInstKey, AppKey: appKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ShowAppInstances failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return nil, c.handleErrorResponse(resp, "ShowAppInstances")
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return []AppInstance{}, nil // Return empty slice for not found
|
||||
}
|
||||
|
||||
var appInstances []AppInstance
|
||||
if appInstances, err = parseStreamingResponse[AppInstance](resp); err != nil {
|
||||
return nil, fmt.Errorf("ShowAppInstances failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
c.logf("ShowAppInstances: found %d app instances matching criteria", len(appInstances))
|
||||
return appInstances, nil
|
||||
}
|
||||
|
||||
// UpdateAppInstance updates an application instance and then refreshes it
|
||||
// Maps to POST /auth/ctrl/UpdateAppInst
|
||||
func (c *Client) UpdateAppInstance(ctx context.Context, input *UpdateAppInstanceInput) error {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/UpdateAppInst"
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("UpdateAppInstance failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "UpdateAppInstance")
|
||||
}
|
||||
|
||||
c.logf("UpdateAppInstance: %s/%s updated successfully",
|
||||
input.AppInst.Key.Organization, input.AppInst.Key.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshAppInstance refreshes an application instance's state
|
||||
// Maps to POST /auth/ctrl/RefreshAppInst
|
||||
func (c *Client) RefreshAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/RefreshAppInst"
|
||||
|
||||
filter := AppInstanceFilter{
|
||||
AppInstance: AppInstance{Key: appInstKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("RefreshAppInstance failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "RefreshAppInstance")
|
||||
}
|
||||
|
||||
c.logf("RefreshAppInstance: %s/%s refreshed successfully",
|
||||
appInstKey.Organization, appInstKey.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAppInstance removes an application instance
|
||||
// Maps to POST /auth/ctrl/DeleteAppInst
|
||||
func (c *Client) DeleteAppInstance(ctx context.Context, appInstKey AppInstanceKey, region string) error {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteAppInst"
|
||||
|
||||
input := DeleteAppInstanceInput{
|
||||
Region: region,
|
||||
}
|
||||
input.AppInst.Key = appInstKey
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteAppInstance failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// 404 is acceptable for delete operations (already deleted)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return c.handleErrorResponse(resp, "DeleteAppInstance")
|
||||
}
|
||||
|
||||
c.logf("DeleteAppInstance: %s/%s deleted successfully",
|
||||
appInstKey.Organization, appInstKey.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseStreamingAppInstanceResponse parses the EdgeXR streaming JSON response format for app instances
|
||||
func parseStreamingResponse[T Message](resp *http.Response) ([]T, error) {
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return []T{}, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// todo finish check the responses, test them, and make a unify result, probably need
|
||||
// to update the response parameter to the message type e.g. App or AppInst
|
||||
isV2, err := isV2Response(bodyBytes)
|
||||
if err != nil {
|
||||
return []T{}, fmt.Errorf("failed to parse streaming response: %w", err)
|
||||
}
|
||||
|
||||
if isV2 {
|
||||
resultV2, err := parseStreamingResponseV2[T](resp.StatusCode, bodyBytes)
|
||||
if err != nil {
|
||||
return []T{}, err
|
||||
}
|
||||
return resultV2, nil
|
||||
}
|
||||
|
||||
resultV1, err := parseStreamingResponseV1[T](resp.StatusCode, bodyBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resultV1.IsSuccessful() {
|
||||
return []T{}, resultV1.Error()
|
||||
}
|
||||
|
||||
return resultV1.GetData(), nil
|
||||
}
|
||||
|
||||
func parseStreamingResponseV1[T Message](statusCode int, bodyBytes []byte) (Responses[T], error) {
|
||||
// Fall back to streaming format (v1 API format)
|
||||
var responses Responses[T]
|
||||
responses.StatusCode = statusCode
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(bodyBytes))
|
||||
for {
|
||||
var d Response[T]
|
||||
if err := decoder.Decode(&d); err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
break
|
||||
}
|
||||
return Responses[T]{}, fmt.Errorf("error in parsing json object into Message: %w", err)
|
||||
}
|
||||
|
||||
if d.Result.Message != "" && d.Result.Code != 0 {
|
||||
responses.StatusCode = d.Result.Code
|
||||
}
|
||||
|
||||
if strings.Contains(d.Data.GetMessage(), "CreateError") {
|
||||
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "CreateError"))
|
||||
}
|
||||
|
||||
if strings.Contains(d.Data.GetMessage(), "UpdateError") {
|
||||
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "UpdateError"))
|
||||
}
|
||||
|
||||
if strings.Contains(d.Data.GetMessage(), "DeleteError") {
|
||||
responses.Errors = append(responses.Errors, fmt.Errorf("server responded with: %s", "DeleteError"))
|
||||
}
|
||||
|
||||
responses.Responses = append(responses.Responses, d)
|
||||
}
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
func isV2Response(bodyBytes []byte) (bool, error) {
|
||||
if len(bodyBytes) == 0 {
|
||||
return false, fmt.Errorf("malformatted response body")
|
||||
}
|
||||
|
||||
return bodyBytes[0] == '[', nil
|
||||
}
|
||||
|
||||
func parseStreamingResponseV2[T Message](statusCode int, bodyBytes []byte) ([]T, error) {
|
||||
var result []T
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return result, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
527
sdk/edgeconnect/v2/appinstance_test.go
Normal file
527
sdk/edgeconnect/v2/appinstance_test.go
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
// ABOUTME: Unit tests for AppInstance management APIs using httptest mock server
|
||||
// ABOUTME: Tests create, show, list, refresh, and delete operations with error conditions
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateAppInstance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *NewAppInstanceInput
|
||||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "successful creation",
|
||||
input: &NewAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
AppKey: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Flavor: Flavor{Name: "m4.small"},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"message": "success"}`,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "validation error",
|
||||
input: &NewAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: "",
|
||||
Name: "testinst",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 400,
|
||||
mockResponse: `{"message": "organization is required"}`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "HTTP 200 with CreateError message",
|
||||
input: &NewAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
Flavor: Flavor{Name: "m4.small"},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"data":{"message":"Creating"}}
|
||||
{"data":{"message":"a service has been configured"}}
|
||||
{"data":{"message":"CreateError"}}
|
||||
{"data":{"message":"Deleting AppInst due to failure"}}
|
||||
{"data":{"message":"Deleted AppInst successfully"}}`,
|
||||
expectError: true,
|
||||
errorContains: "CreateError",
|
||||
},
|
||||
{
|
||||
name: "HTTP 200 with result error code",
|
||||
input: &NewAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
Flavor: Flavor{Name: "m4.small"},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"data":{"message":"Creating"}}
|
||||
{"data":{"message":"a service has been configured"}}
|
||||
{"data":{"message":"CreateError"}}
|
||||
{"data":{"message":"Deleting AppInst due to failure"}}
|
||||
{"data":{"message":"Deleted AppInst successfully"}}
|
||||
{"result":{"message":"Encountered failures: Create App Inst failed: deployments.apps is forbidden: User \"system:serviceaccount:edgexr:crm-telekomop-munich\" cannot create resource \"deployments\" in API group \"apps\" in the namespace \"gitea\"","code":400}}`,
|
||||
expectError: true,
|
||||
errorContains: "deployments.apps is forbidden",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/CreateAppInst", r.URL.Path)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create client
|
||||
client := NewClient(server.URL,
|
||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
||||
)
|
||||
|
||||
// Execute test
|
||||
ctx := context.Background()
|
||||
err := client.CreateAppInstance(ctx, tt.input)
|
||||
|
||||
// Verify results
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
if tt.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowAppInstance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appInstKey AppInstanceKey
|
||||
appKey AppKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
expectNotFound bool
|
||||
}{
|
||||
{
|
||||
name: "successful show",
|
||||
appInstKey: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
appKey: AppKey{Name: "testapp"},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testinst", "cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}}, "state": "Ready"}}
|
||||
`,
|
||||
expectError: false,
|
||||
expectNotFound: false,
|
||||
},
|
||||
{
|
||||
name: "instance not found",
|
||||
appInstKey: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "nonexistent",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
appKey: AppKey{Name: "testapp"},
|
||||
region: "us-west",
|
||||
mockStatusCode: 404,
|
||||
mockResponse: "",
|
||||
expectError: true,
|
||||
expectNotFound: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create client
|
||||
client := NewClient(server.URL,
|
||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
||||
)
|
||||
|
||||
// Execute test
|
||||
ctx := context.Background()
|
||||
appInst, err := client.ShowAppInstance(ctx, tt.appInstKey, tt.appKey, tt.region)
|
||||
|
||||
// Verify results
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
if tt.expectNotFound {
|
||||
assert.Contains(t, err.Error(), "resource not found")
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.appInstKey.Organization, appInst.Key.Organization)
|
||||
assert.Equal(t, tt.appInstKey.Name, appInst.Key.Name)
|
||||
assert.Equal(t, "Ready", appInst.State)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowAppInstances(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowAppInst", r.URL.Path)
|
||||
|
||||
// Verify request body
|
||||
var filter AppInstanceFilter
|
||||
err := json.NewDecoder(r.Body).Decode(&filter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testorg", filter.AppInstance.Key.Organization)
|
||||
assert.Equal(t, "us-west", filter.Region)
|
||||
|
||||
// Return multiple app instances
|
||||
response := `{"data": {"key": {"organization": "testorg", "name": "inst1"}, "state": "Ready"}}
|
||||
{"data": {"key": {"organization": "testorg", "name": "inst2"}, "state": "Creating"}}
|
||||
`
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
appInstances, err := client.ShowAppInstances(ctx, AppInstanceKey{Organization: "testorg"}, AppKey{}, "us-west")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, appInstances, 2)
|
||||
assert.Equal(t, "inst1", appInstances[0].Key.Name)
|
||||
assert.Equal(t, "Ready", appInstances[0].State)
|
||||
assert.Equal(t, "inst2", appInstances[1].Key.Name)
|
||||
assert.Equal(t, "Creating", appInstances[1].State)
|
||||
}
|
||||
|
||||
func TestUpdateAppInstance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *UpdateAppInstanceInput
|
||||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "successful update",
|
||||
input: &UpdateAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
AppKey: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Flavor: Flavor{Name: "m4.medium"},
|
||||
PowerState: "PowerOn",
|
||||
},
|
||||
},
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"message": "success"}`,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "validation error",
|
||||
input: &UpdateAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: "",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 400,
|
||||
mockResponse: `{"message": "organization is required"}`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "instance not found",
|
||||
input: &UpdateAppInstanceInput{
|
||||
Region: "us-west",
|
||||
AppInst: AppInstance{
|
||||
Key: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "nonexistent",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 404,
|
||||
mockResponse: `{"message": "app instance not found"}`,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/UpdateAppInst", r.URL.Path)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
// Verify request body
|
||||
var input UpdateAppInstanceInput
|
||||
err := json.NewDecoder(r.Body).Decode(&input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.input.Region, input.Region)
|
||||
assert.Equal(t, tt.input.AppInst.Key.Organization, input.AppInst.Key.Organization)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create client
|
||||
client := NewClient(server.URL,
|
||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
||||
)
|
||||
|
||||
// Execute test
|
||||
ctx := context.Background()
|
||||
err := client.UpdateAppInstance(ctx, tt.input)
|
||||
|
||||
// Verify results
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshAppInstance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appInstKey AppInstanceKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "successful refresh",
|
||||
appInstKey: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
appInstKey: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 500,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/RefreshAppInst", r.URL.Path)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.RefreshAppInstance(ctx, tt.appInstKey, tt.region)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAppInstance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appInstKey AppInstanceKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "successful deletion",
|
||||
appInstKey: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "already deleted (404 ok)",
|
||||
appInstKey: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 404,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
appInstKey: AppInstanceKey{
|
||||
Organization: "testorg",
|
||||
Name: "testinst",
|
||||
CloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 500,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/DeleteAppInst", r.URL.Path)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.DeleteAppInstance(ctx, tt.appInstKey, tt.region)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
213
sdk/edgeconnect/v2/apps.go
Normal file
213
sdk/edgeconnect/v2/apps.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
// ABOUTME: Application lifecycle management APIs for EdgeXR Master Controller
|
||||
// ABOUTME: Provides typed methods for creating, querying, and deleting applications
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrResourceNotFound indicates the requested resource was not found
|
||||
ErrResourceNotFound = fmt.Errorf("resource not found")
|
||||
)
|
||||
|
||||
// CreateApp creates a new application in the specified region
|
||||
// Maps to POST /auth/ctrl/CreateApp
|
||||
func (c *Client) CreateApp(ctx context.Context, input *NewAppInput) error {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/CreateApp"
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateApp failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "CreateApp")
|
||||
}
|
||||
|
||||
c.logf("CreateApp: %s/%s version %s created successfully",
|
||||
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShowApp retrieves a single application by key and region
|
||||
// Maps to POST /auth/ctrl/ShowApp
|
||||
func (c *Client) ShowApp(ctx context.Context, appKey AppKey, region string) (App, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp"
|
||||
|
||||
filter := AppFilter{
|
||||
App: App{Key: appKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return App{}, fmt.Errorf("ShowApp failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
|
||||
appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return App{}, c.handleErrorResponse(resp, "ShowApp")
|
||||
}
|
||||
|
||||
// Parse streaming JSON response
|
||||
var apps []App
|
||||
if apps, err = parseStreamingResponse[App](resp); err != nil {
|
||||
return App{}, fmt.Errorf("ShowApp failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(apps) == 0 {
|
||||
return App{}, fmt.Errorf("app %s/%s version %s in region %s: %w",
|
||||
appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound)
|
||||
}
|
||||
|
||||
return apps[0], nil
|
||||
}
|
||||
|
||||
// ShowApps retrieves all applications matching the filter criteria
|
||||
// Maps to POST /auth/ctrl/ShowApp
|
||||
func (c *Client) ShowApps(ctx context.Context, appKey AppKey, region string) ([]App, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowApp"
|
||||
|
||||
filter := AppFilter{
|
||||
App: App{Key: appKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ShowApps failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return nil, c.handleErrorResponse(resp, "ShowApps")
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return []App{}, nil // Return empty slice for not found
|
||||
}
|
||||
|
||||
var apps []App
|
||||
if apps, err = parseStreamingResponse[App](resp); err != nil {
|
||||
return nil, fmt.Errorf("ShowApps failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
c.logf("ShowApps: found %d apps matching criteria", len(apps))
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// UpdateApp updates the definition of an application
|
||||
// Maps to POST /auth/ctrl/UpdateApp
|
||||
func (c *Client) UpdateApp(ctx context.Context, input *UpdateAppInput) error {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/UpdateApp"
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("UpdateApp failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "UpdateApp")
|
||||
}
|
||||
|
||||
c.logf("UpdateApp: %s/%s version %s updated successfully",
|
||||
input.App.Key.Organization, input.App.Key.Name, input.App.Key.Version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteApp removes an application from the specified region
|
||||
// Maps to POST /auth/ctrl/DeleteApp
|
||||
func (c *Client) DeleteApp(ctx context.Context, appKey AppKey, region string) error {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteApp"
|
||||
|
||||
input := DeleteAppInput{
|
||||
Region: region,
|
||||
}
|
||||
input.App.Key = appKey
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteApp failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// 404 is acceptable for delete operations (already deleted)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return c.handleErrorResponse(resp, "DeleteApp")
|
||||
}
|
||||
|
||||
c.logf("DeleteApp: %s/%s version %s deleted successfully",
|
||||
appKey.Organization, appKey.Name, appKey.Version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTransport creates an HTTP transport with current client settings
|
||||
func (c *Client) getTransport() *sdkhttp.Transport {
|
||||
return sdkhttp.NewTransport(
|
||||
sdkhttp.RetryOptions{
|
||||
MaxRetries: c.RetryOpts.MaxRetries,
|
||||
InitialDelay: c.RetryOpts.InitialDelay,
|
||||
MaxDelay: c.RetryOpts.MaxDelay,
|
||||
Multiplier: c.RetryOpts.Multiplier,
|
||||
RetryableHTTPStatusCodes: c.RetryOpts.RetryableHTTPStatusCodes,
|
||||
},
|
||||
c.AuthProvider,
|
||||
c.Logger,
|
||||
)
|
||||
}
|
||||
|
||||
// handleErrorResponse creates an appropriate error from HTTP error response
|
||||
func (c *Client) handleErrorResponse(resp *http.Response, operation string) error {
|
||||
|
||||
messages := []string{
|
||||
fmt.Sprintf("%s failed with status %d", operation, resp.StatusCode),
|
||||
}
|
||||
|
||||
bodyBytes := []byte{}
|
||||
|
||||
if resp.Body != nil {
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
bodyBytes, _ = io.ReadAll(resp.Body)
|
||||
messages = append(messages, string(bodyBytes))
|
||||
}
|
||||
|
||||
return &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Messages: messages,
|
||||
Body: bodyBytes,
|
||||
}
|
||||
}
|
||||
409
sdk/edgeconnect/v2/apps_test.go
Normal file
409
sdk/edgeconnect/v2/apps_test.go
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
// ABOUTME: Unit tests for App management APIs using httptest mock server
|
||||
// ABOUTME: Tests create, show, list, and delete operations with error conditions
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *NewAppInput
|
||||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "successful creation",
|
||||
input: &NewAppInput{
|
||||
Region: "us-west",
|
||||
App: App{
|
||||
Key: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Deployment: "kubernetes",
|
||||
},
|
||||
},
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"message": "success"}`,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "validation error",
|
||||
input: &NewAppInput{
|
||||
Region: "us-west",
|
||||
App: App{
|
||||
Key: AppKey{
|
||||
Organization: "",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 400,
|
||||
mockResponse: `{"message": "organization is required"}`,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/CreateApp", r.URL.Path)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create client
|
||||
client := NewClient(server.URL,
|
||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
||||
)
|
||||
|
||||
// Execute test
|
||||
ctx := context.Background()
|
||||
err := client.CreateApp(ctx, tt.input)
|
||||
|
||||
// Verify results
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appKey AppKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
expectNotFound bool
|
||||
}{
|
||||
{
|
||||
name: "successful show",
|
||||
appKey: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"data": {"key": {"organization": "testorg", "name": "testapp", "version": "1.0.0"}, "deployment": "kubernetes"}}
|
||||
`,
|
||||
expectError: false,
|
||||
expectNotFound: false,
|
||||
},
|
||||
{
|
||||
name: "app not found",
|
||||
appKey: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "nonexistent",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 404,
|
||||
mockResponse: "",
|
||||
expectError: true,
|
||||
expectNotFound: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create client
|
||||
client := NewClient(server.URL,
|
||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
||||
)
|
||||
|
||||
// Execute test
|
||||
ctx := context.Background()
|
||||
app, err := client.ShowApp(ctx, tt.appKey, tt.region)
|
||||
|
||||
// Verify results
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
if tt.expectNotFound {
|
||||
assert.Contains(t, err.Error(), "resource not found")
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.appKey.Organization, app.Key.Organization)
|
||||
assert.Equal(t, tt.appKey.Name, app.Key.Name)
|
||||
assert.Equal(t, tt.appKey.Version, app.Key.Version)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowApps(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowApp", r.URL.Path)
|
||||
|
||||
// Verify request body
|
||||
var filter AppFilter
|
||||
err := json.NewDecoder(r.Body).Decode(&filter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testorg", filter.App.Key.Organization)
|
||||
assert.Equal(t, "us-west", filter.Region)
|
||||
|
||||
// Return multiple apps
|
||||
response := `{"data": {"key": {"organization": "testorg", "name": "app1", "version": "1.0.0"}, "deployment": "kubernetes"}}
|
||||
{"data": {"key": {"organization": "testorg", "name": "app2", "version": "1.0.0"}, "deployment": "docker"}}
|
||||
`
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
apps, err := client.ShowApps(ctx, AppKey{Organization: "testorg"}, "us-west")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, apps, 2)
|
||||
assert.Equal(t, "app1", apps[0].Key.Name)
|
||||
assert.Equal(t, "app2", apps[1].Key.Name)
|
||||
}
|
||||
|
||||
func TestUpdateApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *UpdateAppInput
|
||||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "successful update",
|
||||
input: &UpdateAppInput{
|
||||
Region: "us-west",
|
||||
App: App{
|
||||
Key: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Deployment: "kubernetes",
|
||||
ImagePath: "nginx:latest",
|
||||
},
|
||||
},
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"message": "success"}`,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "validation error",
|
||||
input: &UpdateAppInput{
|
||||
Region: "us-west",
|
||||
App: App{
|
||||
Key: AppKey{
|
||||
Organization: "",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 400,
|
||||
mockResponse: `{"message": "organization is required"}`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "app not found",
|
||||
input: &UpdateAppInput{
|
||||
Region: "us-west",
|
||||
App: App{
|
||||
Key: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "nonexistent",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 404,
|
||||
mockResponse: `{"message": "app not found"}`,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/UpdateApp", r.URL.Path)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
// Verify request body
|
||||
var input UpdateAppInput
|
||||
err := json.NewDecoder(r.Body).Decode(&input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.input.Region, input.Region)
|
||||
assert.Equal(t, tt.input.App.Key.Organization, input.App.Key.Organization)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create client
|
||||
client := NewClient(server.URL,
|
||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
||||
)
|
||||
|
||||
// Execute test
|
||||
ctx := context.Background()
|
||||
err := client.UpdateApp(ctx, tt.input)
|
||||
|
||||
// Verify results
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appKey AppKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "successful deletion",
|
||||
appKey: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "already deleted (404 ok)",
|
||||
appKey: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 404,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
appKey: AppKey{
|
||||
Organization: "testorg",
|
||||
Name: "testapp",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 500,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/DeleteApp", r.URL.Path)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.DeleteApp(ctx, tt.appKey, tt.region)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientOptions(t *testing.T) {
|
||||
t.Run("with auth provider", func(t *testing.T) {
|
||||
authProvider := NewStaticTokenProvider("test-token")
|
||||
client := NewClient("https://example.com",
|
||||
WithAuthProvider(authProvider),
|
||||
)
|
||||
|
||||
assert.Equal(t, authProvider, client.AuthProvider)
|
||||
})
|
||||
|
||||
t.Run("with custom HTTP client", func(t *testing.T) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
client := NewClient("https://example.com",
|
||||
WithHTTPClient(httpClient),
|
||||
)
|
||||
|
||||
assert.Equal(t, httpClient, client.HTTPClient)
|
||||
})
|
||||
|
||||
t.Run("with retry options", func(t *testing.T) {
|
||||
retryOpts := RetryOptions{MaxRetries: 5}
|
||||
client := NewClient("https://example.com",
|
||||
WithRetryOptions(retryOpts),
|
||||
)
|
||||
|
||||
assert.Equal(t, 5, client.RetryOpts.MaxRetries)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIError(t *testing.T) {
|
||||
err := &APIError{
|
||||
StatusCode: 400,
|
||||
Messages: []string{"validation failed", "name is required"},
|
||||
}
|
||||
|
||||
assert.Contains(t, err.Error(), "validation failed")
|
||||
assert.Equal(t, 400, err.StatusCode)
|
||||
assert.Len(t, err.Messages, 2)
|
||||
}
|
||||
186
sdk/edgeconnect/v2/auth.go
Normal file
186
sdk/edgeconnect/v2/auth.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// ABOUTME: Authentication providers for EdgeXR Master Controller API
|
||||
// ABOUTME: Supports Bearer token authentication with pluggable provider interface
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthProvider interface for attaching authentication to requests
|
||||
type AuthProvider interface {
|
||||
// Attach adds authentication headers to the request
|
||||
Attach(ctx context.Context, req *http.Request) error
|
||||
}
|
||||
|
||||
// StaticTokenProvider implements Bearer token authentication with a fixed token
|
||||
type StaticTokenProvider struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
// NewStaticTokenProvider creates a new static token provider
|
||||
func NewStaticTokenProvider(token string) *StaticTokenProvider {
|
||||
return &StaticTokenProvider{Token: token}
|
||||
}
|
||||
|
||||
// Attach adds the Bearer token to the request Authorization header
|
||||
func (s *StaticTokenProvider) Attach(ctx context.Context, req *http.Request) error {
|
||||
if s.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.Token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsernamePasswordProvider implements dynamic token retrieval using username/password
|
||||
// This matches the existing client/client.go RetrieveToken implementation
|
||||
type UsernamePasswordProvider struct {
|
||||
BaseURL string
|
||||
Username string
|
||||
Password string
|
||||
HTTPClient *http.Client
|
||||
|
||||
// Token caching
|
||||
mu sync.RWMutex
|
||||
cachedToken string
|
||||
tokenExpiry time.Time
|
||||
}
|
||||
|
||||
// NewUsernamePasswordProvider creates a new username/password auth provider
|
||||
func NewUsernamePasswordProvider(baseURL, username, password string, httpClient *http.Client) *UsernamePasswordProvider {
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
|
||||
return &UsernamePasswordProvider{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Username: username,
|
||||
Password: password,
|
||||
HTTPClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Attach retrieves a token (with caching) and adds it to the Authorization header
|
||||
func (u *UsernamePasswordProvider) Attach(ctx context.Context, req *http.Request) error {
|
||||
token, err := u.getToken(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get token: %w", err)
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getToken retrieves a token, using cache if valid
|
||||
func (u *UsernamePasswordProvider) getToken(ctx context.Context) (string, error) {
|
||||
// Check cache first
|
||||
u.mu.RLock()
|
||||
if u.cachedToken != "" && time.Now().Before(u.tokenExpiry) {
|
||||
token := u.cachedToken
|
||||
u.mu.RUnlock()
|
||||
return token, nil
|
||||
}
|
||||
u.mu.RUnlock()
|
||||
|
||||
// Need to retrieve new token
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if u.cachedToken != "" && time.Now().Before(u.tokenExpiry) {
|
||||
return u.cachedToken, nil
|
||||
}
|
||||
|
||||
// Retrieve token using existing RetrieveToken logic
|
||||
token, err := u.retrieveToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Cache token with reasonable expiry (assume 1 hour, can be configurable)
|
||||
u.cachedToken = token
|
||||
u.tokenExpiry = time.Now().Add(1 * time.Hour)
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// retrieveToken implements the same logic as the existing client/client.go RetrieveToken method
|
||||
func (u *UsernamePasswordProvider) retrieveToken(ctx context.Context) (string, error) {
|
||||
// Marshal credentials - same as existing implementation
|
||||
jsonData, err := json.Marshal(map[string]string{
|
||||
"username": u.Username,
|
||||
"password": u.Password,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create request - same as existing implementation
|
||||
loginURL := u.BaseURL + "/api/v1/login"
|
||||
request, err := http.NewRequestWithContext(ctx, "POST", loginURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Execute request
|
||||
resp, err := u.HTTPClient.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// Read response body - same as existing implementation
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error reading response body: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse JSON response - same as existing implementation
|
||||
var respData struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
err = json.Unmarshal(body, &respData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing JSON (status %d): %v", resp.StatusCode, err)
|
||||
}
|
||||
|
||||
return respData.Token, nil
|
||||
}
|
||||
|
||||
// InvalidateToken clears the cached token, forcing a new login on next request
|
||||
func (u *UsernamePasswordProvider) InvalidateToken() {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
u.cachedToken = ""
|
||||
u.tokenExpiry = time.Time{}
|
||||
}
|
||||
|
||||
// NoAuthProvider implements no authentication (for testing or public endpoints)
|
||||
type NoAuthProvider struct{}
|
||||
|
||||
// NewNoAuthProvider creates a new no-auth provider
|
||||
func NewNoAuthProvider() *NoAuthProvider {
|
||||
return &NoAuthProvider{}
|
||||
}
|
||||
|
||||
// Attach does nothing (no authentication)
|
||||
func (n *NoAuthProvider) Attach(ctx context.Context, req *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
226
sdk/edgeconnect/v2/auth_test.go
Normal file
226
sdk/edgeconnect/v2/auth_test.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
// ABOUTME: Unit tests for authentication providers including username/password token flow
|
||||
// ABOUTME: Tests token caching, login flow, and error conditions with mock servers
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStaticTokenProvider(t *testing.T) {
|
||||
provider := NewStaticTokenProvider("test-token-123")
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
ctx := context.Background()
|
||||
|
||||
err := provider.Attach(ctx, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Bearer test-token-123", req.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestStaticTokenProvider_EmptyToken(t *testing.T) {
|
||||
provider := NewStaticTokenProvider("")
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
ctx := context.Background()
|
||||
|
||||
err := provider.Attach(ctx, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, req.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestUsernamePasswordProvider_Success(t *testing.T) {
|
||||
// Mock login server
|
||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/login", r.URL.Path)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
// Verify request body
|
||||
var creds map[string]string
|
||||
err := json.NewDecoder(r.Body).Decode(&creds)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testuser", creds["username"])
|
||||
assert.Equal(t, "testpass", creds["password"])
|
||||
|
||||
// Return token
|
||||
response := map[string]string{"token": "dynamic-token-456"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
||||
ctx := context.Background()
|
||||
|
||||
err := provider.Attach(ctx, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Bearer dynamic-token-456", req.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestUsernamePasswordProvider_LoginFailure(t *testing.T) {
|
||||
// Mock login server that returns error
|
||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("Invalid credentials"))
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
provider := NewUsernamePasswordProvider(loginServer.URL, "baduser", "badpass", nil)
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
||||
ctx := context.Background()
|
||||
|
||||
err := provider.Attach(ctx, req)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "login failed with status 401")
|
||||
assert.Contains(t, err.Error(), "Invalid credentials")
|
||||
}
|
||||
|
||||
func TestUsernamePasswordProvider_TokenCaching(t *testing.T) {
|
||||
callCount := 0
|
||||
|
||||
// Mock login server that tracks calls
|
||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
response := map[string]string{"token": "cached-token-789"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// First request should call login
|
||||
req1, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
||||
err1 := provider.Attach(ctx, req1)
|
||||
require.NoError(t, err1)
|
||||
assert.Equal(t, "Bearer cached-token-789", req1.Header.Get("Authorization"))
|
||||
assert.Equal(t, 1, callCount)
|
||||
|
||||
// Second request should use cached token (no additional login call)
|
||||
req2, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
||||
err2 := provider.Attach(ctx, req2)
|
||||
require.NoError(t, err2)
|
||||
assert.Equal(t, "Bearer cached-token-789", req2.Header.Get("Authorization"))
|
||||
assert.Equal(t, 1, callCount) // Still only 1 call
|
||||
}
|
||||
|
||||
func TestUsernamePasswordProvider_TokenExpiry(t *testing.T) {
|
||||
callCount := 0
|
||||
|
||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
response := map[string]string{"token": "refreshed-token-999"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
||||
|
||||
// Manually set expired token
|
||||
provider.mu.Lock()
|
||||
provider.cachedToken = "expired-token"
|
||||
provider.tokenExpiry = time.Now().Add(-1 * time.Hour) // Already expired
|
||||
provider.mu.Unlock()
|
||||
|
||||
ctx := context.Background()
|
||||
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
||||
|
||||
err := provider.Attach(ctx, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Bearer refreshed-token-999", req.Header.Get("Authorization"))
|
||||
assert.Equal(t, 1, callCount) // New token retrieved
|
||||
}
|
||||
|
||||
func TestUsernamePasswordProvider_InvalidateToken(t *testing.T) {
|
||||
callCount := 0
|
||||
|
||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
response := map[string]string{"token": "new-token-after-invalidation"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// First request to get token
|
||||
req1, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
||||
err1 := provider.Attach(ctx, req1)
|
||||
require.NoError(t, err1)
|
||||
assert.Equal(t, 1, callCount)
|
||||
|
||||
// Invalidate token
|
||||
provider.InvalidateToken()
|
||||
|
||||
// Next request should get new token
|
||||
req2, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
||||
err2 := provider.Attach(ctx, req2)
|
||||
require.NoError(t, err2)
|
||||
assert.Equal(t, "Bearer new-token-after-invalidation", req2.Header.Get("Authorization"))
|
||||
assert.Equal(t, 2, callCount) // New login call made
|
||||
}
|
||||
|
||||
func TestUsernamePasswordProvider_BadJSONResponse(t *testing.T) {
|
||||
// Mock server returning invalid JSON
|
||||
loginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("invalid json response"))
|
||||
}))
|
||||
defer loginServer.Close()
|
||||
|
||||
provider := NewUsernamePasswordProvider(loginServer.URL, "testuser", "testpass", nil)
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
|
||||
ctx := context.Background()
|
||||
|
||||
err := provider.Attach(ctx, req)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error parsing JSON")
|
||||
}
|
||||
|
||||
func TestNoAuthProvider(t *testing.T) {
|
||||
provider := NewNoAuthProvider()
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
ctx := context.Background()
|
||||
|
||||
err := provider.Attach(ctx, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, req.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestNewClientWithCredentials(t *testing.T) {
|
||||
client := NewClientWithCredentials("https://example.com", "testuser", "testpass")
|
||||
|
||||
assert.Equal(t, "https://example.com", client.BaseURL)
|
||||
|
||||
// Check that auth provider is UsernamePasswordProvider
|
||||
authProvider, ok := client.AuthProvider.(*UsernamePasswordProvider)
|
||||
require.True(t, ok, "AuthProvider should be UsernamePasswordProvider")
|
||||
assert.Equal(t, "testuser", authProvider.Username)
|
||||
assert.Equal(t, "testpass", authProvider.Password)
|
||||
assert.Equal(t, "https://example.com", authProvider.BaseURL)
|
||||
}
|
||||
122
sdk/edgeconnect/v2/client.go
Normal file
122
sdk/edgeconnect/v2/client.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// ABOUTME: Core EdgeXR Master Controller SDK client with HTTP transport and auth
|
||||
// ABOUTME: Provides typed APIs for app, instance, and cloudlet management operations
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client represents the EdgeXR Master Controller SDK client
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
AuthProvider AuthProvider
|
||||
RetryOpts RetryOptions
|
||||
Logger Logger
|
||||
}
|
||||
|
||||
// RetryOptions configures retry behavior for API calls
|
||||
type RetryOptions struct {
|
||||
MaxRetries int
|
||||
InitialDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
Multiplier float64
|
||||
RetryableHTTPStatusCodes []int
|
||||
}
|
||||
|
||||
// Logger interface for optional logging
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
// DefaultRetryOptions returns sensible default retry configuration
|
||||
func DefaultRetryOptions() RetryOptions {
|
||||
return RetryOptions{
|
||||
MaxRetries: 3,
|
||||
InitialDelay: 1 * time.Second,
|
||||
MaxDelay: 30 * time.Second,
|
||||
Multiplier: 2.0,
|
||||
RetryableHTTPStatusCodes: []int{
|
||||
http.StatusRequestTimeout,
|
||||
http.StatusTooManyRequests,
|
||||
http.StatusInternalServerError,
|
||||
http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable,
|
||||
http.StatusGatewayTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Option represents a configuration option for the client
|
||||
type Option func(*Client)
|
||||
|
||||
// WithHTTPClient sets a custom HTTP client
|
||||
func WithHTTPClient(client *http.Client) Option {
|
||||
return func(c *Client) {
|
||||
c.HTTPClient = client
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthProvider sets the authentication provider
|
||||
func WithAuthProvider(auth AuthProvider) Option {
|
||||
return func(c *Client) {
|
||||
c.AuthProvider = auth
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryOptions sets retry configuration
|
||||
func WithRetryOptions(opts RetryOptions) Option {
|
||||
return func(c *Client) {
|
||||
c.RetryOpts = opts
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets a logger for debugging
|
||||
func WithLogger(logger Logger) Option {
|
||||
return func(c *Client) {
|
||||
c.Logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient creates a new EdgeXR SDK client
|
||||
func NewClient(baseURL string, options ...Option) *Client {
|
||||
client := &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
AuthProvider: NewNoAuthProvider(),
|
||||
RetryOpts: DefaultRetryOptions(),
|
||||
}
|
||||
|
||||
for _, opt := range options {
|
||||
opt(client)
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// NewClientWithCredentials creates a new EdgeXR SDK client with username/password authentication
|
||||
// This matches the existing client pattern from client/client.go
|
||||
func NewClientWithCredentials(baseURL, username, password string, options ...Option) *Client {
|
||||
client := &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
AuthProvider: NewUsernamePasswordProvider(baseURL, username, password, nil),
|
||||
RetryOpts: DefaultRetryOptions(),
|
||||
}
|
||||
|
||||
for _, opt := range options {
|
||||
opt(client)
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// logf logs a message if a logger is configured
|
||||
func (c *Client) logf(format string, v ...interface{}) {
|
||||
if c.Logger != nil {
|
||||
c.Logger.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
283
sdk/edgeconnect/v2/cloudlet.go
Normal file
283
sdk/edgeconnect/v2/cloudlet.go
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
// ABOUTME: Cloudlet management APIs for EdgeXR Master Controller
|
||||
// ABOUTME: Provides typed methods for creating, querying, and managing edge cloudlets
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/internal/http"
|
||||
)
|
||||
|
||||
// CreateCloudlet creates a new cloudlet in the specified region
|
||||
// Maps to POST /auth/ctrl/CreateCloudlet
|
||||
func (c *Client) CreateCloudlet(ctx context.Context, input *NewCloudletInput) error {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/CreateCloudlet"
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateCloudlet failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return c.handleErrorResponse(resp, "CreateCloudlet")
|
||||
}
|
||||
|
||||
c.logf("CreateCloudlet: %s/%s created successfully",
|
||||
input.Cloudlet.Key.Organization, input.Cloudlet.Key.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShowCloudlet retrieves a single cloudlet by key and region
|
||||
// Maps to POST /auth/ctrl/ShowCloudlet
|
||||
func (c *Client) ShowCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) (Cloudlet, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet"
|
||||
|
||||
filter := CloudletFilter{
|
||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
|
||||
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return Cloudlet{}, c.handleErrorResponse(resp, "ShowCloudlet")
|
||||
}
|
||||
|
||||
// Parse streaming JSON response
|
||||
var cloudlets []Cloudlet
|
||||
if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil {
|
||||
return Cloudlet{}, fmt.Errorf("ShowCloudlet failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(cloudlets) == 0 {
|
||||
return Cloudlet{}, fmt.Errorf("cloudlet %s/%s in region %s: %w",
|
||||
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
|
||||
}
|
||||
|
||||
return cloudlets[0], nil
|
||||
}
|
||||
|
||||
// ShowCloudlets retrieves all cloudlets matching the filter criteria
|
||||
// Maps to POST /auth/ctrl/ShowCloudlet
|
||||
func (c *Client) ShowCloudlets(ctx context.Context, cloudletKey CloudletKey, region string) ([]Cloudlet, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/ShowCloudlet"
|
||||
|
||||
filter := CloudletFilter{
|
||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ShowCloudlets failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return nil, c.handleErrorResponse(resp, "ShowCloudlets")
|
||||
}
|
||||
|
||||
var cloudlets []Cloudlet
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return cloudlets, nil // Return empty slice for not found
|
||||
}
|
||||
|
||||
if err := c.parseStreamingCloudletResponse(resp, &cloudlets); err != nil {
|
||||
return nil, fmt.Errorf("ShowCloudlets failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
c.logf("ShowCloudlets: found %d cloudlets matching criteria", len(cloudlets))
|
||||
return cloudlets, nil
|
||||
}
|
||||
|
||||
// DeleteCloudlet removes a cloudlet from the specified region
|
||||
// Maps to POST /auth/ctrl/DeleteCloudlet
|
||||
func (c *Client) DeleteCloudlet(ctx context.Context, cloudletKey CloudletKey, region string) error {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/DeleteCloudlet"
|
||||
|
||||
filter := CloudletFilter{
|
||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteCloudlet failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// 404 is acceptable for delete operations (already deleted)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusNotFound {
|
||||
return c.handleErrorResponse(resp, "DeleteCloudlet")
|
||||
}
|
||||
|
||||
c.logf("DeleteCloudlet: %s/%s deleted successfully",
|
||||
cloudletKey.Organization, cloudletKey.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCloudletManifest retrieves the deployment manifest for a cloudlet
|
||||
// Maps to POST /auth/ctrl/GetCloudletManifest
|
||||
func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletManifest, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletManifest"
|
||||
|
||||
filter := CloudletFilter{
|
||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetCloudletManifest failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w",
|
||||
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, c.handleErrorResponse(resp, "GetCloudletManifest")
|
||||
}
|
||||
|
||||
// Parse the response as CloudletManifest
|
||||
var manifest CloudletManifest
|
||||
if err := c.parseDirectJSONResponse(resp, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("GetCloudletManifest failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
c.logf("GetCloudletManifest: retrieved manifest for %s/%s",
|
||||
cloudletKey.Organization, cloudletKey.Name)
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// GetCloudletResourceUsage retrieves resource usage information for a cloudlet
|
||||
// Maps to POST /auth/ctrl/GetCloudletResourceUsage
|
||||
func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey CloudletKey, region string) (*CloudletResourceUsage, error) {
|
||||
transport := c.getTransport()
|
||||
url := c.BaseURL + "/api/v1/auth/ctrl/GetCloudletResourceUsage"
|
||||
|
||||
filter := CloudletFilter{
|
||||
Cloudlet: Cloudlet{Key: cloudletKey},
|
||||
Region: region,
|
||||
}
|
||||
|
||||
resp, err := transport.Call(ctx, "POST", url, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetCloudletResourceUsage failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w",
|
||||
cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, c.handleErrorResponse(resp, "GetCloudletResourceUsage")
|
||||
}
|
||||
|
||||
// Parse the response as CloudletResourceUsage
|
||||
var usage CloudletResourceUsage
|
||||
if err := c.parseDirectJSONResponse(resp, &usage); err != nil {
|
||||
return nil, fmt.Errorf("GetCloudletResourceUsage failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
c.logf("GetCloudletResourceUsage: retrieved usage for %s/%s",
|
||||
cloudletKey.Organization, cloudletKey.Name)
|
||||
|
||||
return &usage, nil
|
||||
}
|
||||
|
||||
// parseStreamingCloudletResponse parses the EdgeXR streaming JSON response format for cloudlets
|
||||
func (c *Client) parseStreamingCloudletResponse(resp *http.Response, result interface{}) error {
|
||||
var responses []Response[Cloudlet]
|
||||
|
||||
parseErr := sdkhttp.ParseJSONLines(resp.Body, func(line []byte) error {
|
||||
var response Response[Cloudlet]
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
responses = append(responses, response)
|
||||
return nil
|
||||
})
|
||||
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
|
||||
// Extract data from responses
|
||||
var cloudlets []Cloudlet
|
||||
var messages []string
|
||||
|
||||
for _, response := range responses {
|
||||
if response.HasData() {
|
||||
cloudlets = append(cloudlets, response.Data)
|
||||
}
|
||||
if response.IsMessage() {
|
||||
messages = append(messages, response.Data.GetMessage())
|
||||
}
|
||||
}
|
||||
|
||||
// If we have error messages, return them
|
||||
if len(messages) > 0 {
|
||||
return &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Messages: messages,
|
||||
}
|
||||
}
|
||||
|
||||
// Set result based on type
|
||||
switch v := result.(type) {
|
||||
case *[]Cloudlet:
|
||||
*v = cloudlets
|
||||
default:
|
||||
return fmt.Errorf("unsupported result type: %T", result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDirectJSONResponse parses a direct JSON response (not streaming)
|
||||
func (c *Client) parseDirectJSONResponse(resp *http.Response, result interface{}) error {
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
if err := decoder.Decode(result); err != nil {
|
||||
return fmt.Errorf("failed to decode JSON response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
408
sdk/edgeconnect/v2/cloudlet_test.go
Normal file
408
sdk/edgeconnect/v2/cloudlet_test.go
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
// ABOUTME: Unit tests for Cloudlet management APIs using httptest mock server
|
||||
// ABOUTME: Tests create, show, list, delete, manifest, and resource usage operations
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateCloudlet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *NewCloudletInput
|
||||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "successful creation",
|
||||
input: &NewCloudletInput{
|
||||
Region: "us-west",
|
||||
Cloudlet: Cloudlet{
|
||||
Key: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
Location: Location{
|
||||
Latitude: 37.7749,
|
||||
Longitude: -122.4194,
|
||||
},
|
||||
IpSupport: "IpSupportDynamic",
|
||||
NumDynamicIps: 10,
|
||||
},
|
||||
},
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"message": "success"}`,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "validation error",
|
||||
input: &NewCloudletInput{
|
||||
Region: "us-west",
|
||||
Cloudlet: Cloudlet{
|
||||
Key: CloudletKey{
|
||||
Organization: "",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
},
|
||||
},
|
||||
mockStatusCode: 400,
|
||||
mockResponse: `{"message": "organization is required"}`,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/CreateCloudlet", r.URL.Path)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create client
|
||||
client := NewClient(server.URL,
|
||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
||||
WithAuthProvider(NewStaticTokenProvider("test-token")),
|
||||
)
|
||||
|
||||
// Execute test
|
||||
ctx := context.Background()
|
||||
err := client.CreateCloudlet(ctx, tt.input)
|
||||
|
||||
// Verify results
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowCloudlet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cloudletKey CloudletKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
expectNotFound bool
|
||||
}{
|
||||
{
|
||||
name: "successful show",
|
||||
cloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"data": {"key": {"organization": "cloudletorg", "name": "testcloudlet"}, "state": "Ready", "location": {"latitude": 37.7749, "longitude": -122.4194}}}
|
||||
`,
|
||||
expectError: false,
|
||||
expectNotFound: false,
|
||||
},
|
||||
{
|
||||
name: "cloudlet not found",
|
||||
cloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "nonexistent",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 404,
|
||||
mockResponse: "",
|
||||
expectError: true,
|
||||
expectNotFound: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create client
|
||||
client := NewClient(server.URL,
|
||||
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
|
||||
)
|
||||
|
||||
// Execute test
|
||||
ctx := context.Background()
|
||||
cloudlet, err := client.ShowCloudlet(ctx, tt.cloudletKey, tt.region)
|
||||
|
||||
// Verify results
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
if tt.expectNotFound {
|
||||
assert.Contains(t, err.Error(), "resource not found")
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.cloudletKey.Organization, cloudlet.Key.Organization)
|
||||
assert.Equal(t, tt.cloudletKey.Name, cloudlet.Key.Name)
|
||||
assert.Equal(t, "Ready", cloudlet.State)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowCloudlets(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/ShowCloudlet", r.URL.Path)
|
||||
|
||||
// Verify request body
|
||||
var filter CloudletFilter
|
||||
err := json.NewDecoder(r.Body).Decode(&filter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "cloudletorg", filter.Cloudlet.Key.Organization)
|
||||
assert.Equal(t, "us-west", filter.Region)
|
||||
|
||||
// Return multiple cloudlets
|
||||
response := `{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet1"}, "state": "Ready"}}
|
||||
{"data": {"key": {"organization": "cloudletorg", "name": "cloudlet2"}, "state": "Creating"}}
|
||||
`
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
cloudlets, err := client.ShowCloudlets(ctx, CloudletKey{Organization: "cloudletorg"}, "us-west")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cloudlets, 2)
|
||||
assert.Equal(t, "cloudlet1", cloudlets[0].Key.Name)
|
||||
assert.Equal(t, "Ready", cloudlets[0].State)
|
||||
assert.Equal(t, "cloudlet2", cloudlets[1].Key.Name)
|
||||
assert.Equal(t, "Creating", cloudlets[1].State)
|
||||
}
|
||||
|
||||
func TestDeleteCloudlet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cloudletKey CloudletKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "successful deletion",
|
||||
cloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "already deleted (404 ok)",
|
||||
cloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 404,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
cloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 500,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/DeleteCloudlet", r.URL.Path)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.DeleteCloudlet(ctx, tt.cloudletKey, tt.region)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCloudletManifest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cloudletKey CloudletKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
expectNotFound bool
|
||||
}{
|
||||
{
|
||||
name: "successful manifest retrieval",
|
||||
cloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"manifest": "apiVersion: v1\nkind: Deployment\nmetadata:\n name: test", "last_modified": "2024-01-01T00:00:00Z"}`,
|
||||
expectError: false,
|
||||
expectNotFound: false,
|
||||
},
|
||||
{
|
||||
name: "manifest not found",
|
||||
cloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "nonexistent",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 404,
|
||||
mockResponse: "",
|
||||
expectError: true,
|
||||
expectNotFound: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletManifest", r.URL.Path)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
manifest, err := client.GetCloudletManifest(ctx, tt.cloudletKey, tt.region)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
if tt.expectNotFound {
|
||||
assert.Contains(t, err.Error(), "resource not found")
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, manifest)
|
||||
assert.Contains(t, manifest.Manifest, "apiVersion: v1")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCloudletResourceUsage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cloudletKey CloudletKey
|
||||
region string
|
||||
mockStatusCode int
|
||||
mockResponse string
|
||||
expectError bool
|
||||
expectNotFound bool
|
||||
}{
|
||||
{
|
||||
name: "successful usage retrieval",
|
||||
cloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "testcloudlet",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 200,
|
||||
mockResponse: `{"cloudlet_key": {"organization": "cloudletorg", "name": "testcloudlet"}, "region": "us-west", "usage": {"cpu": "50%", "memory": "30%", "disk": "20%"}}`,
|
||||
expectError: false,
|
||||
expectNotFound: false,
|
||||
},
|
||||
{
|
||||
name: "usage not found",
|
||||
cloudletKey: CloudletKey{
|
||||
Organization: "cloudletorg",
|
||||
Name: "nonexistent",
|
||||
},
|
||||
region: "us-west",
|
||||
mockStatusCode: 404,
|
||||
mockResponse: "",
|
||||
expectError: true,
|
||||
expectNotFound: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/auth/ctrl/GetCloudletResourceUsage", r.URL.Path)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != "" {
|
||||
_, _ = w.Write([]byte(tt.mockResponse))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
usage, err := client.GetCloudletResourceUsage(ctx, tt.cloudletKey, tt.region)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
if tt.expectNotFound {
|
||||
assert.Contains(t, err.Error(), "resource not found")
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, usage)
|
||||
assert.Equal(t, "cloudletorg", usage.CloudletKey.Organization)
|
||||
assert.Equal(t, "testcloudlet", usage.CloudletKey.Name)
|
||||
assert.Equal(t, "us-west", usage.Region)
|
||||
assert.Contains(t, usage.Usage, "cpu")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
421
sdk/edgeconnect/v2/types.go
Normal file
421
sdk/edgeconnect/v2/types.go
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
// ABOUTME: Core type definitions for EdgeXR Master Controller SDK
|
||||
// ABOUTME: These types are based on the swagger API specification and existing client patterns
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// App field constants for partial updates (based on EdgeXR API specification)
|
||||
const (
|
||||
AppFieldKey = "2"
|
||||
AppFieldKeyOrganization = "2.1"
|
||||
AppFieldKeyName = "2.2"
|
||||
AppFieldKeyVersion = "2.3"
|
||||
AppFieldImagePath = "4"
|
||||
AppFieldImageType = "5"
|
||||
AppFieldAccessPorts = "7"
|
||||
AppFieldDefaultFlavor = "9"
|
||||
AppFieldDefaultFlavorName = "9.1"
|
||||
AppFieldAuthPublicKey = "12"
|
||||
AppFieldCommand = "13"
|
||||
AppFieldAnnotations = "14"
|
||||
AppFieldDeployment = "15"
|
||||
AppFieldDeploymentManifest = "16"
|
||||
AppFieldDeploymentGenerator = "17"
|
||||
AppFieldAndroidPackageName = "18"
|
||||
AppFieldDelOpt = "20"
|
||||
AppFieldConfigs = "21"
|
||||
AppFieldConfigsKind = "21.1"
|
||||
AppFieldConfigsConfig = "21.2"
|
||||
AppFieldScaleWithCluster = "22"
|
||||
AppFieldInternalPorts = "23"
|
||||
AppFieldRevision = "24"
|
||||
AppFieldOfficialFqdn = "25"
|
||||
AppFieldMd5Sum = "26"
|
||||
AppFieldAutoProvPolicy = "28"
|
||||
AppFieldAccessType = "29"
|
||||
AppFieldDeletePrepare = "31"
|
||||
AppFieldAutoProvPolicies = "32"
|
||||
AppFieldTemplateDelimiter = "33"
|
||||
AppFieldSkipHcPorts = "34"
|
||||
AppFieldCreatedAt = "35"
|
||||
AppFieldCreatedAtSeconds = "35.1"
|
||||
AppFieldCreatedAtNanos = "35.2"
|
||||
AppFieldUpdatedAt = "36"
|
||||
AppFieldUpdatedAtSeconds = "36.1"
|
||||
AppFieldUpdatedAtNanos = "36.2"
|
||||
AppFieldTrusted = "37"
|
||||
AppFieldRequiredOutboundConnections = "38"
|
||||
AppFieldAllowServerless = "39"
|
||||
AppFieldServerlessConfig = "40"
|
||||
AppFieldVmAppOsType = "41"
|
||||
AppFieldAlertPolicies = "42"
|
||||
AppFieldQosSessionProfile = "43"
|
||||
AppFieldQosSessionDuration = "44"
|
||||
)
|
||||
|
||||
// AppInstance field constants for partial updates (based on EdgeXR API specification)
|
||||
const (
|
||||
AppInstFieldKey = "2"
|
||||
AppInstFieldKeyAppKey = "2.1"
|
||||
AppInstFieldKeyAppKeyOrganization = "2.1.1"
|
||||
AppInstFieldKeyAppKeyName = "2.1.2"
|
||||
AppInstFieldKeyAppKeyVersion = "2.1.3"
|
||||
AppInstFieldKeyClusterInstKey = "2.4"
|
||||
AppInstFieldKeyClusterInstKeyClusterKey = "2.4.1"
|
||||
AppInstFieldKeyClusterInstKeyClusterKeyName = "2.4.1.1"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKey = "2.4.2"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKeyOrganization = "2.4.2.1"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKeyName = "2.4.2.2"
|
||||
AppInstFieldKeyClusterInstKeyCloudletKeyFederatedOrganization = "2.4.2.3"
|
||||
AppInstFieldKeyClusterInstKeyOrganization = "2.4.3"
|
||||
AppInstFieldCloudletLoc = "3"
|
||||
AppInstFieldCloudletLocLatitude = "3.1"
|
||||
AppInstFieldCloudletLocLongitude = "3.2"
|
||||
AppInstFieldCloudletLocHorizontalAccuracy = "3.3"
|
||||
AppInstFieldCloudletLocVerticalAccuracy = "3.4"
|
||||
AppInstFieldCloudletLocAltitude = "3.5"
|
||||
AppInstFieldCloudletLocCourse = "3.6"
|
||||
AppInstFieldCloudletLocSpeed = "3.7"
|
||||
AppInstFieldCloudletLocTimestamp = "3.8"
|
||||
AppInstFieldCloudletLocTimestampSeconds = "3.8.1"
|
||||
AppInstFieldCloudletLocTimestampNanos = "3.8.2"
|
||||
AppInstFieldUri = "4"
|
||||
AppInstFieldLiveness = "6"
|
||||
AppInstFieldMappedPorts = "9"
|
||||
AppInstFieldMappedPortsProto = "9.1"
|
||||
AppInstFieldMappedPortsInternalPort = "9.2"
|
||||
AppInstFieldMappedPortsPublicPort = "9.3"
|
||||
AppInstFieldMappedPortsFqdnPrefix = "9.5"
|
||||
AppInstFieldMappedPortsEndPort = "9.6"
|
||||
AppInstFieldMappedPortsTls = "9.7"
|
||||
AppInstFieldMappedPortsNginx = "9.8"
|
||||
AppInstFieldMappedPortsMaxPktSize = "9.9"
|
||||
AppInstFieldFlavor = "12"
|
||||
AppInstFieldFlavorName = "12.1"
|
||||
AppInstFieldState = "14"
|
||||
AppInstFieldErrors = "15"
|
||||
AppInstFieldCrmOverride = "16"
|
||||
AppInstFieldRuntimeInfo = "17"
|
||||
AppInstFieldRuntimeInfoContainerIds = "17.1"
|
||||
AppInstFieldCreatedAt = "21"
|
||||
AppInstFieldCreatedAtSeconds = "21.1"
|
||||
AppInstFieldCreatedAtNanos = "21.2"
|
||||
AppInstFieldAutoClusterIpAccess = "22"
|
||||
AppInstFieldRevision = "24"
|
||||
AppInstFieldForceUpdate = "25"
|
||||
AppInstFieldUpdateMultiple = "26"
|
||||
AppInstFieldConfigs = "27"
|
||||
AppInstFieldConfigsKind = "27.1"
|
||||
AppInstFieldConfigsConfig = "27.2"
|
||||
AppInstFieldHealthCheck = "29"
|
||||
AppInstFieldPowerState = "31"
|
||||
AppInstFieldExternalVolumeSize = "32"
|
||||
AppInstFieldAvailabilityZone = "33"
|
||||
AppInstFieldVmFlavor = "34"
|
||||
AppInstFieldOptRes = "35"
|
||||
AppInstFieldUpdatedAt = "36"
|
||||
AppInstFieldUpdatedAtSeconds = "36.1"
|
||||
AppInstFieldUpdatedAtNanos = "36.2"
|
||||
AppInstFieldRealClusterName = "37"
|
||||
AppInstFieldInternalPortToLbIp = "38"
|
||||
AppInstFieldInternalPortToLbIpKey = "38.1"
|
||||
AppInstFieldInternalPortToLbIpValue = "38.2"
|
||||
AppInstFieldDedicatedIp = "39"
|
||||
AppInstFieldUniqueId = "40"
|
||||
AppInstFieldDnsLabel = "41"
|
||||
)
|
||||
|
||||
// Message interface for types that can provide error messages
|
||||
type Message interface {
|
||||
GetMessage() string
|
||||
}
|
||||
|
||||
// Base message type for API responses
|
||||
type msg struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (m msg) GetMessage() string {
|
||||
return m.Message
|
||||
}
|
||||
|
||||
// AppKey uniquely identifies an application
|
||||
type AppKey struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// CloudletKey uniquely identifies a cloudlet
|
||||
type CloudletKey struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AppInstanceKey uniquely identifies an application instance
|
||||
type AppInstanceKey struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name"`
|
||||
CloudletKey CloudletKey `json:"cloudlet_key"`
|
||||
}
|
||||
|
||||
// Flavor defines resource allocation for instances
|
||||
type Flavor struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SecurityRule defines network access rules
|
||||
type SecurityRule struct {
|
||||
PortRangeMax int `json:"port_range_max"`
|
||||
PortRangeMin int `json:"port_range_min"`
|
||||
Protocol string `json:"protocol"`
|
||||
RemoteCIDR string `json:"remote_cidr"`
|
||||
}
|
||||
|
||||
// App represents an application definition
|
||||
type App struct {
|
||||
msg `json:",inline"`
|
||||
Key AppKey `json:"key"`
|
||||
Deployment string `json:"deployment,omitempty"`
|
||||
ImageType string `json:"image_type,omitempty"`
|
||||
ImagePath string `json:"image_path,omitempty"`
|
||||
AccessPorts string `json:"access_ports,omitempty"`
|
||||
AllowServerless bool `json:"allow_serverless,omitempty"`
|
||||
DefaultFlavor Flavor `json:"defaultFlavor,omitempty"`
|
||||
ServerlessConfig interface{} `json:"serverless_config,omitempty"`
|
||||
DeploymentGenerator string `json:"deployment_generator,omitempty"`
|
||||
DeploymentManifest string `json:"deployment_manifest,omitempty"`
|
||||
RequiredOutboundConnections []SecurityRule `json:"required_outbound_connections"`
|
||||
GlobalID string `json:"global_id,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// AppInstance represents a deployed application instance
|
||||
type AppInstance struct {
|
||||
msg `json:",inline"`
|
||||
Key AppInstanceKey `json:"key"`
|
||||
AppKey AppKey `json:"app_key,omitempty"`
|
||||
CloudletLoc CloudletLoc `json:"cloudlet_loc,omitempty"`
|
||||
Flavor Flavor `json:"flavor,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
IngressURL string `json:"ingress_url,omitempty"`
|
||||
UniqueID string `json:"unique_id,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
PowerState string `json:"power_state,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// Cloudlet represents edge infrastructure
|
||||
type Cloudlet struct {
|
||||
msg `json:",inline"`
|
||||
Key CloudletKey `json:"key"`
|
||||
Location Location `json:"location"`
|
||||
IpSupport string `json:"ip_support,omitempty"`
|
||||
NumDynamicIps int32 `json:"num_dynamic_ips,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Flavor Flavor `json:"flavor,omitempty"`
|
||||
PhysicalName string `json:"physical_name,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
NotifySrvAddr string `json:"notify_srv_addr,omitempty"`
|
||||
}
|
||||
|
||||
// Location represents geographical coordinates
|
||||
type Location struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
// CloudletLoc represents geographical coordinates for cloudlets
|
||||
type CloudletLoc struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
// Input types for API operations
|
||||
|
||||
// NewAppInput represents input for creating an application
|
||||
type NewAppInput struct {
|
||||
Region string `json:"region"`
|
||||
App App `json:"app"`
|
||||
}
|
||||
|
||||
// NewAppInstanceInput represents input for creating an app instance
|
||||
type NewAppInstanceInput struct {
|
||||
Region string `json:"region"`
|
||||
AppInst AppInstance `json:"appinst"`
|
||||
}
|
||||
|
||||
// NewCloudletInput represents input for creating a cloudlet
|
||||
type NewCloudletInput struct {
|
||||
Region string `json:"region"`
|
||||
Cloudlet Cloudlet `json:"cloudlet"`
|
||||
}
|
||||
|
||||
// UpdateAppInput represents input for updating an application
|
||||
type UpdateAppInput struct {
|
||||
Region string `json:"region"`
|
||||
App App `json:"app"`
|
||||
}
|
||||
|
||||
// UpdateAppInstanceInput represents input for updating an app instance
|
||||
type UpdateAppInstanceInput struct {
|
||||
Region string `json:"region"`
|
||||
AppInst AppInstance `json:"appinst"`
|
||||
}
|
||||
|
||||
// DeleteAppInput represents input for deleting an application
|
||||
type DeleteAppInput struct {
|
||||
Region string `json:"region"`
|
||||
App struct {
|
||||
Key AppKey `json:"key"`
|
||||
} `json:"app"`
|
||||
}
|
||||
|
||||
// DeleteAppInstanceInput represents input for deleting an app instance
|
||||
type DeleteAppInstanceInput struct {
|
||||
Region string `json:"region"`
|
||||
AppInst struct {
|
||||
Key AppInstanceKey `json:"key"`
|
||||
} `json:"appinst"`
|
||||
}
|
||||
|
||||
// Response wrapper types
|
||||
|
||||
// Response wraps a single API response
|
||||
type Response[T Message] struct {
|
||||
ResultResponse `json:",inline"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
func (res *Response[T]) HasData() bool {
|
||||
return !res.IsMessage()
|
||||
}
|
||||
|
||||
func (res *Response[T]) IsMessage() bool {
|
||||
return res.Data.GetMessage() != ""
|
||||
}
|
||||
|
||||
// ResultResponse represents an API result with error code
|
||||
type ResultResponse struct {
|
||||
Result struct {
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
func (r *ResultResponse) IsError() bool {
|
||||
return r.Result.Code >= 400
|
||||
}
|
||||
|
||||
func (r *ResultResponse) GetMessage() string {
|
||||
return r.Result.Message
|
||||
}
|
||||
|
||||
func (r *ResultResponse) GetCode() int {
|
||||
return r.Result.Code
|
||||
}
|
||||
|
||||
// Responses wraps multiple API responses with metadata
|
||||
type Responses[T Message] struct {
|
||||
Responses []Response[T] `json:"responses,omitempty"`
|
||||
StatusCode int `json:"-"`
|
||||
Errors []error `json:"-"`
|
||||
}
|
||||
|
||||
func (r *Responses[T]) GetData() []T {
|
||||
var data []T
|
||||
for _, v := range r.Responses {
|
||||
if v.HasData() {
|
||||
data = append(data, v.Data)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (r *Responses[T]) GetMessages() []string {
|
||||
var messages []string
|
||||
for _, v := range r.Responses {
|
||||
if v.IsMessage() {
|
||||
messages = append(messages, v.Data.GetMessage())
|
||||
}
|
||||
if v.Result.Message != "" {
|
||||
messages = append(messages, v.Result.Message)
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func (r *Responses[T]) IsSuccessful() bool {
|
||||
return len(r.Errors) == 0 && (r.StatusCode >= 200 && r.StatusCode < 400)
|
||||
}
|
||||
|
||||
func (r *Responses[T]) Error() error {
|
||||
if r.IsSuccessful() {
|
||||
return nil
|
||||
}
|
||||
return &APIError{
|
||||
StatusCode: r.StatusCode,
|
||||
Messages: r.GetMessages(),
|
||||
}
|
||||
}
|
||||
|
||||
// APIError represents an API error with details
|
||||
type APIError struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Messages []string `json:"messages,omitempty"`
|
||||
Body []byte `json:"-"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
jsonErr, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("API error: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("API error: %s", jsonErr)
|
||||
}
|
||||
|
||||
// Filter types for querying
|
||||
|
||||
// AppFilter represents filters for app queries
|
||||
type AppFilter struct {
|
||||
App App `json:"app"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// AppInstanceFilter represents filters for app instance queries
|
||||
type AppInstanceFilter struct {
|
||||
AppInstance AppInstance `json:"appinst"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// CloudletFilter represents filters for cloudlet queries
|
||||
type CloudletFilter struct {
|
||||
Cloudlet Cloudlet `json:"cloudlet"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// CloudletManifest represents cloudlet deployment manifest
|
||||
type CloudletManifest struct {
|
||||
Manifest string `json:"manifest"`
|
||||
LastModified time.Time `json:"last_modified,omitempty"`
|
||||
}
|
||||
|
||||
// CloudletResourceUsage represents cloudlet resource utilization
|
||||
type CloudletResourceUsage struct {
|
||||
CloudletKey CloudletKey `json:"cloudlet_key"`
|
||||
Region string `json:"region"`
|
||||
Usage map[string]interface{} `json:"usage"`
|
||||
}
|
||||
|
||||
type ErrorMessage struct {
|
||||
Message string
|
||||
}
|
||||
|
|
@ -3,17 +3,17 @@
|
|||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "edge-app-demo" # name could be used for appName
|
||||
appVersion: "1.0.0"
|
||||
organization: "edp2"
|
||||
spec:
|
||||
# dockerApp: # Docker is OBSOLETE
|
||||
# appVersion: "1.0.0"
|
||||
# manifestFile: "./docker-compose.yaml"
|
||||
# image: "https://registry-1.docker.io/library/nginx:latest"
|
||||
k8sApp:
|
||||
appVersion: "1.0.0"
|
||||
manifestFile: "./k8s-deployment.yaml" # store hash of the manifest file in annotation field. Annotations is a comma separated map of arbitrary key value pairs,
|
||||
manifestFile: "./k8s-deployment.yaml"
|
||||
infraTemplate:
|
||||
- organization: "edp2"
|
||||
region: "EU"
|
||||
- region: "EU"
|
||||
cloudletOrg: "TelekomOP"
|
||||
cloudletName: "Munich"
|
||||
flavorName: "EU.small"
|
||||
29
sdk/examples/comprehensive/EdgeConnectConfig_v2.yaml
Normal file
29
sdk/examples/comprehensive/EdgeConnectConfig_v2.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Is there a swagger file for the new EdgeConnect API?
|
||||
# How does it differ from the EdgeXR API?
|
||||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "edge-app-demo" # name could be used for appName
|
||||
appVersion: "1"
|
||||
organization: "edp2-orca"
|
||||
spec:
|
||||
# dockerApp: # Docker is OBSOLETE
|
||||
# appVersion: "1.0.0"
|
||||
# manifestFile: "./docker-compose.yaml"
|
||||
# image: "https://registry-1.docker.io/library/nginx:latest"
|
||||
k8sApp:
|
||||
manifestFile: "./k8s-deployment.yaml"
|
||||
infraTemplate:
|
||||
- region: "US"
|
||||
cloudletOrg: "TelekomOp"
|
||||
cloudletName: "gardener-shepherd-test"
|
||||
flavorName: "defualt"
|
||||
network:
|
||||
outboundConnections:
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 80
|
||||
portRangeMax: 80
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 443
|
||||
portRangeMax: 443
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
|
|
@ -18,6 +18,7 @@ apiVersion: apps/v1
|
|||
kind: Deployment
|
||||
metadata:
|
||||
name: edgeconnect-coder-deployment
|
||||
#namespace: gitea
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
|
|
@ -32,7 +33,7 @@ spec:
|
|||
volumes:
|
||||
containers:
|
||||
- name: edgeconnect-coder
|
||||
image: nginx:latest
|
||||
image: edp.buildth.ing/devfw-cicd/fibonacci_pipeline:edge_platform_demo
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -24,20 +24,20 @@ func main() {
|
|||
username := getEnvOrDefault("EDGEXR_USERNAME", "")
|
||||
password := getEnvOrDefault("EDGEXR_PASSWORD", "")
|
||||
|
||||
var client *edgeconnect.Client
|
||||
var client *v2.Client
|
||||
|
||||
if token != "" {
|
||||
fmt.Println("🔐 Using Bearer token authentication")
|
||||
client = edgeconnect.NewClient(baseURL,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)),
|
||||
edgeconnect.WithLogger(log.Default()),
|
||||
client = v2.NewClient(baseURL,
|
||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)),
|
||||
v2.WithLogger(log.Default()),
|
||||
)
|
||||
} else if username != "" && password != "" {
|
||||
fmt.Println("🔐 Using username/password authentication")
|
||||
client = edgeconnect.NewClientWithCredentials(baseURL, username, password,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect.WithLogger(log.Default()),
|
||||
client = v2.NewClientWithCredentials(baseURL, username, password,
|
||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
v2.WithLogger(log.Default()),
|
||||
)
|
||||
} else {
|
||||
log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD")
|
||||
|
|
@ -85,15 +85,15 @@ type WorkflowConfig struct {
|
|||
FlavorName string
|
||||
}
|
||||
|
||||
func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config WorkflowConfig) error {
|
||||
func runComprehensiveWorkflow(ctx context.Context, c *v2.Client, config WorkflowConfig) error {
|
||||
fmt.Println("═══ Phase 1: Application Management ═══")
|
||||
|
||||
// 1. Create Application
|
||||
fmt.Println("\n1️⃣ Creating application...")
|
||||
app := &edgeconnect.NewAppInput{
|
||||
app := &v2.NewAppInput{
|
||||
Region: config.Region,
|
||||
App: edgeconnect.App{
|
||||
Key: edgeconnect.AppKey{
|
||||
App: v2.App{
|
||||
Key: v2.AppKey{
|
||||
Organization: config.Organization,
|
||||
Name: config.AppName,
|
||||
Version: config.AppVersion,
|
||||
|
|
@ -101,10 +101,10 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
Deployment: "kubernetes",
|
||||
ImageType: "ImageTypeDocker", // field is ignored
|
||||
ImagePath: "https://registry-1.docker.io/library/nginx:latest", // must be set. Even for kubernetes
|
||||
DefaultFlavor: edgeconnect.Flavor{Name: config.FlavorName},
|
||||
DefaultFlavor: v2.Flavor{Name: config.FlavorName},
|
||||
ServerlessConfig: struct{}{}, // must be set
|
||||
AllowServerless: true, // must be set to true for kubernetes
|
||||
RequiredOutboundConnections: []edgeconnect.SecurityRule{
|
||||
RequiredOutboundConnections: []v2.SecurityRule{
|
||||
{
|
||||
Protocol: "tcp",
|
||||
PortRangeMin: 80,
|
||||
|
|
@ -128,7 +128,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 2. Show Application Details
|
||||
fmt.Println("\n2️⃣ Querying application details...")
|
||||
appKey := edgeconnect.AppKey{
|
||||
appKey := v2.AppKey{
|
||||
Organization: config.Organization,
|
||||
Name: config.AppName,
|
||||
Version: config.AppVersion,
|
||||
|
|
@ -146,7 +146,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 3. List Applications in Organization
|
||||
fmt.Println("\n3️⃣ Listing applications in organization...")
|
||||
filter := edgeconnect.AppKey{Organization: config.Organization}
|
||||
filter := v2.AppKey{Organization: config.Organization}
|
||||
apps, err := c.ShowApps(ctx, filter, config.Region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list apps: %w", err)
|
||||
|
|
@ -160,19 +160,19 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 4. Create Application Instance
|
||||
fmt.Println("\n4️⃣ Creating application instance...")
|
||||
instance := &edgeconnect.NewAppInstanceInput{
|
||||
instance := &v2.NewAppInstanceInput{
|
||||
Region: config.Region,
|
||||
AppInst: edgeconnect.AppInstance{
|
||||
Key: edgeconnect.AppInstanceKey{
|
||||
AppInst: v2.AppInstance{
|
||||
Key: v2.AppInstanceKey{
|
||||
Organization: config.Organization,
|
||||
Name: config.InstanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: config.CloudletOrg,
|
||||
Name: config.CloudletName,
|
||||
},
|
||||
},
|
||||
AppKey: appKey,
|
||||
Flavor: edgeconnect.Flavor{Name: config.FlavorName},
|
||||
Flavor: v2.Flavor{Name: config.FlavorName},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -184,16 +184,16 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 5. Wait for Application Instance to be Ready
|
||||
fmt.Println("\n5️⃣ Waiting for application instance to be ready...")
|
||||
instanceKey := edgeconnect.AppInstanceKey{
|
||||
instanceKey := v2.AppInstanceKey{
|
||||
Organization: config.Organization,
|
||||
Name: config.InstanceName,
|
||||
CloudletKey: edgeconnect.CloudletKey{
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: config.CloudletOrg,
|
||||
Name: config.CloudletName,
|
||||
},
|
||||
}
|
||||
|
||||
instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, config.Region, 5*time.Minute)
|
||||
instanceDetails, err := waitForInstanceReady(ctx, c, instanceKey, v2.AppKey{}, config.Region, 5*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to wait for instance ready: %w", err)
|
||||
}
|
||||
|
|
@ -207,7 +207,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 6. List Application Instances
|
||||
fmt.Println("\n6️⃣ Listing application instances...")
|
||||
instances, err := c.ShowAppInstances(ctx, edgeconnect.AppInstanceKey{Organization: config.Organization}, config.Region)
|
||||
instances, err := c.ShowAppInstances(ctx, v2.AppInstanceKey{Organization: config.Organization}, v2.AppKey{}, config.Region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list app instances: %w", err)
|
||||
}
|
||||
|
|
@ -228,7 +228,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
|
||||
// 8. Show Cloudlet Details
|
||||
fmt.Println("\n8️⃣ Querying cloudlet information...")
|
||||
cloudletKey := edgeconnect.CloudletKey{
|
||||
cloudletKey := v2.CloudletKey{
|
||||
Organization: config.CloudletOrg,
|
||||
Name: config.CloudletName,
|
||||
}
|
||||
|
|
@ -287,7 +287,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config
|
|||
// 13. Verify Cleanup
|
||||
fmt.Println("\n1️⃣3️⃣ Verifying cleanup...")
|
||||
_, err = c.ShowApp(ctx, appKey, config.Region)
|
||||
if err != nil && fmt.Sprintf("%v", err) == edgeconnect.ErrResourceNotFound.Error() {
|
||||
if err != nil && fmt.Sprintf("%v", err) == v2.ErrResourceNotFound.Error() {
|
||||
fmt.Printf("✅ Cleanup verified - app no longer exists\n")
|
||||
} else if err != nil {
|
||||
fmt.Printf("✅ Cleanup appears successful (verification returned: %v)\n", err)
|
||||
|
|
@ -306,7 +306,7 @@ func getEnvOrDefault(key, defaultValue string) string {
|
|||
}
|
||||
|
||||
// waitForInstanceReady polls the instance status until it's no longer "Creating" or timeout
|
||||
func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKey edgeconnect.AppInstanceKey, region string, timeout time.Duration) (edgeconnect.AppInstance, error) {
|
||||
func waitForInstanceReady(ctx context.Context, c *v2.Client, instanceKey v2.AppInstanceKey, appKey v2.AppKey, region string, timeout time.Duration) (v2.AppInstance, error) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -318,10 +318,10 @@ func waitForInstanceReady(ctx context.Context, c *edgeconnect.Client, instanceKe
|
|||
for {
|
||||
select {
|
||||
case <-timeoutCtx.Done():
|
||||
return edgeconnect.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout)
|
||||
return v2.AppInstance{}, fmt.Errorf("timeout waiting for instance to be ready after %v", timeout)
|
||||
|
||||
case <-ticker.C:
|
||||
instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, region)
|
||||
instance, err := c.ShowAppInstance(timeoutCtx, instanceKey, appKey, region)
|
||||
if err != nil {
|
||||
// Log error but continue polling
|
||||
fmt.Printf(" ⚠️ Error checking instance state: %v\n", err)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-client/sdk/edgeconnect"
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -24,22 +24,22 @@ func main() {
|
|||
username := getEnvOrDefault("EDGEXR_USERNAME", "")
|
||||
password := getEnvOrDefault("EDGEXR_PASSWORD", "")
|
||||
|
||||
var edgeClient *edgeconnect.Client
|
||||
var edgeClient *v2.Client
|
||||
|
||||
if token != "" {
|
||||
// Use static token authentication
|
||||
fmt.Println("🔐 Using Bearer token authentication")
|
||||
edgeClient = edgeconnect.NewClient(baseURL,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect.WithAuthProvider(edgeconnect.NewStaticTokenProvider(token)),
|
||||
edgeconnect.WithLogger(log.Default()),
|
||||
edgeClient = v2.NewClient(baseURL,
|
||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
v2.WithAuthProvider(v2.NewStaticTokenProvider(token)),
|
||||
v2.WithLogger(log.Default()),
|
||||
)
|
||||
} else if username != "" && password != "" {
|
||||
// Use username/password authentication (matches existing client pattern)
|
||||
fmt.Println("🔐 Using username/password authentication")
|
||||
edgeClient = edgeconnect.NewClientWithCredentials(baseURL, username, password,
|
||||
edgeconnect.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
edgeconnect.WithLogger(log.Default()),
|
||||
edgeClient = v2.NewClientWithCredentials(baseURL, username, password,
|
||||
v2.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
||||
v2.WithLogger(log.Default()),
|
||||
)
|
||||
} else {
|
||||
log.Fatal("Authentication required: Set either EDGEXR_TOKEN or both EDGEXR_USERNAME and EDGEXR_PASSWORD")
|
||||
|
|
@ -48,10 +48,10 @@ func main() {
|
|||
ctx := context.Background()
|
||||
|
||||
// Example application to deploy
|
||||
app := &edgeconnect.NewAppInput{
|
||||
app := &v2.NewAppInput{
|
||||
Region: "EU",
|
||||
App: edgeconnect.App{
|
||||
Key: edgeconnect.AppKey{
|
||||
App: v2.App{
|
||||
Key: v2.AppKey{
|
||||
Organization: "edp2",
|
||||
Name: "my-edge-app",
|
||||
Version: "1.0.0",
|
||||
|
|
@ -59,7 +59,7 @@ func main() {
|
|||
Deployment: "docker",
|
||||
ImageType: "ImageTypeDocker",
|
||||
ImagePath: "https://registry-1.docker.io/library/nginx:latest",
|
||||
DefaultFlavor: edgeconnect.Flavor{Name: "EU.small"},
|
||||
DefaultFlavor: v2.Flavor{Name: "EU.small"},
|
||||
ServerlessConfig: struct{}{},
|
||||
AllowServerless: false,
|
||||
},
|
||||
|
|
@ -73,7 +73,7 @@ func main() {
|
|||
fmt.Println("✅ SDK example completed successfully!")
|
||||
}
|
||||
|
||||
func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client, input *edgeconnect.NewAppInput) error {
|
||||
func demonstrateAppLifecycle(ctx context.Context, edgeClient *v2.Client, input *v2.NewAppInput) error {
|
||||
appKey := input.App.Key
|
||||
region := input.Region
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client
|
|||
|
||||
// Step 3: List applications in the organization
|
||||
fmt.Println("\n3. Listing applications...")
|
||||
filter := edgeconnect.AppKey{Organization: appKey.Organization}
|
||||
filter := v2.AppKey{Organization: appKey.Organization}
|
||||
apps, err := edgeClient.ShowApps(ctx, filter, region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list apps: %w", err)
|
||||
|
|
@ -116,7 +116,7 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client
|
|||
fmt.Println("\n5. Verifying deletion...")
|
||||
_, err = edgeClient.ShowApp(ctx, appKey, region)
|
||||
if err != nil {
|
||||
if strings.Contains(fmt.Sprintf("%v", err), edgeconnect.ErrResourceNotFound.Error()) {
|
||||
if strings.Contains(fmt.Sprintf("%v", err), v2.ErrResourceNotFound.Error()) {
|
||||
fmt.Printf("✅ App successfully deleted (not found)\n")
|
||||
} else {
|
||||
return fmt.Errorf("unexpected error verifying deletion: %w", err)
|
||||
|
|
|
|||
29
sdk/examples/forgejo-runner/EdgeConnectConfig_v1.yaml
Normal file
29
sdk/examples/forgejo-runner/EdgeConnectConfig_v1.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Is there a swagger file for the new EdgeConnect API?
|
||||
# How does it differ from the EdgeXR API?
|
||||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "forgejo-runner-edge" # name could be used for appName
|
||||
appVersion: "1.0.0"
|
||||
organization: "edp2"
|
||||
spec:
|
||||
# dockerApp: # Docker is OBSOLETE
|
||||
# appVersion: "1.0.0"
|
||||
# manifestFile: "./docker-compose.yaml"
|
||||
# image: "https://registry-1.docker.io/library/nginx:latest"
|
||||
k8sApp:
|
||||
manifestFile: "./forgejo-runner-deployment.yaml"
|
||||
infraTemplate:
|
||||
- region: "EU"
|
||||
cloudletOrg: "TelekomOP"
|
||||
cloudletName: "Hamburg"
|
||||
flavorName: "EU.small"
|
||||
network:
|
||||
outboundConnections:
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 80
|
||||
portRangeMax: 80
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 443
|
||||
portRangeMax: 443
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
29
sdk/examples/forgejo-runner/EdgeConnectConfig_v2.yaml
Normal file
29
sdk/examples/forgejo-runner/EdgeConnectConfig_v2.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Is there a swagger file for the new EdgeConnect API?
|
||||
# How does it differ from the EdgeXR API?
|
||||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "forgejo-runner-orca" # name could be used for appName
|
||||
appVersion: "1"
|
||||
organization: "edp2-orca"
|
||||
spec:
|
||||
# dockerApp: # Docker is OBSOLETE
|
||||
# appVersion: "1.0.0"
|
||||
# manifestFile: "./docker-compose.yaml"
|
||||
# image: "https://registry-1.docker.io/library/nginx:latest"
|
||||
k8sApp:
|
||||
manifestFile: "./forgejo-runner-deployment.yaml"
|
||||
infraTemplate:
|
||||
- region: "US"
|
||||
cloudletOrg: "TelekomOp"
|
||||
cloudletName: "gardener-shepherd-test"
|
||||
flavorName: "defualt"
|
||||
network:
|
||||
outboundConnections:
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 80
|
||||
portRangeMax: 80
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 443
|
||||
portRangeMax: 443
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
53
sdk/examples/forgejo-runner/README.md
Normal file
53
sdk/examples/forgejo-runner/README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Forgejo Runner in Edge Connect Example
|
||||
|
||||
Execute in the projects main directory:
|
||||
|
||||
```
|
||||
go run . apply -f forgejo-runner/EdgeConnectConfig.yaml
|
||||
```
|
||||
|
||||
## Improvement: 'create app instance' with full respone body analysis (feature/parsing_createappinstance)
|
||||
|
||||
When we have an errorneous deployment (example: "namespace: gitea" within the manifest) EdgeConnect will reject the deployment at some stage in its creation workflow.
|
||||
Now we grab the error correctly in the workflow-response-array by parsing the whole response body and don't think the deployment worked just of only reading the first successfull step (which was the creation of the app instance):
|
||||
|
||||
```bash
|
||||
(devbox) stl@ubuntu-vpn:~/git/mms/ipcei-cis/edge-connect-client$ go run . apply -f sdk/examples/forgejo-runner/EdgeConnectConfig.yaml
|
||||
📄 Loading configuration from: /home/stl/git/mms/ipcei-cis/edge-connect-client/sdk/examples/forgejo-runner/EdgeConnectConfig.yaml
|
||||
✅ Configuration loaded successfully: forgejo-runner-edge
|
||||
🔍 Analyzing current state and generating deployment plan...
|
||||
|
||||
📋 Deployment Plan:
|
||||
==================================================
|
||||
Deployment plan for 'forgejo-runner-edge':
|
||||
- CREATE application 'forgejo-runner-edge'
|
||||
- Create new application
|
||||
- CREATE 1 instance(s) across 1 cloudlet(s)
|
||||
Estimated duration: 3m0s
|
||||
==================================================
|
||||
|
||||
This will perform 2 actions. Estimated time: 3m0s
|
||||
Do you want to proceed? (yes/no): yes
|
||||
|
||||
🚀 Starting deployment...
|
||||
2025/10/16 16:58:08 [ResourceManager] Starting deployment: forgejo-runner-edge
|
||||
2025/10/16 16:58:08 [ResourceManager] Validating deployment prerequisites for: forgejo-runner-edge
|
||||
2025/10/16 16:58:08 [ResourceManager] Prerequisites validation passed
|
||||
2025/10/16 16:58:08 [ResourceManager] Using deployment strategy: recreate
|
||||
2025/10/16 16:58:08 [ResourceManager] Estimated deployment duration: 8m20s
|
||||
2025/10/16 16:58:08 [RecreateStrategy] Starting recreate deployment strategy for: forgejo-runner-edge
|
||||
2025/10/16 16:58:08 [RecreateStrategy] Phase 1: Deleting existing instances
|
||||
2025/10/16 16:58:08 [RecreateStrategy] No existing instances to delete
|
||||
2025/10/16 16:58:08 [RecreateStrategy] Phase 2: No app deletion needed (new app)
|
||||
2025/10/16 16:58:08 [RecreateStrategy] Phase 3: Creating application
|
||||
2025/10/16 16:58:10 [RecreateStrategy] Successfully created application: edp2/forgejo-runner-edge version 1.0.0
|
||||
2025/10/16 16:58:10 [RecreateStrategy] Phase 3 complete: app created successfully
|
||||
2025/10/16 16:58:10 [RecreateStrategy] Phase 4: Creating new instances
|
||||
2025/10/16 16:58:11 [RecreateStrategy] Failed to create instance forgejo-runner-edge-1.0.0-instance: failed to create instance: ShowAppInstance failed to parse response: API error: {"status_code":400,"code":"400","messages":["Encountered failures: Create App Inst failed: deployments.apps is forbidden: User \"system:serviceaccount:edgexr:crm-telekomop-hamburg\" cannot create resource \"deployments\" in API group \"apps\" in the namespace \"gitea\"","Creating","a service has been configured","your application is accessiable via https://forgejo-runner-test-tcp.apps.edge.platform.mg3.mdb.osc.live","CreateError","Deleting AppInst due to failure","Deleted AppInst successfully"]} (non-retryable error, giving up)
|
||||
2025/10/16 16:58:11 [ResourceManager] Deployment failed, attempting rollback...
|
||||
2025/10/16 16:58:11 [ResourceManager] Starting rollback for deployment: forgejo-runner-edge
|
||||
2025/10/16 16:58:11 [ResourceManager] Successfully rolled back: forgejo-runner-edge
|
||||
2025/10/16 16:58:11 [ResourceManager] Rollback completed successfully
|
||||
Error: deployment failed: failed to create instance forgejo-runner-edge-1.0.0-instance: non-retryable error: failed to create instance: ShowAppInstance failed to parse response: API error: {"status_code":400,"code":"400","messages":["Encountered failures: Create App Inst failed: deployments.apps is forbidden: User \"system:serviceaccount:edgexr:crm-telekomop-hamburg\" cannot create resource \"deployments\" in API group \"apps\" in the namespace \"gitea\"","Creating","a service has been configured","your application is accessiable via https://forgejo-runner-test-tcp.apps.edge.platform.mg3.mdb.osc.live","CreateError","Deleting AppInst due to failure","Deleted AppInst successfully"]}
|
||||
exit status 1
|
||||
```
|
||||
108
sdk/examples/forgejo-runner/forgejo-runner-deployment.yaml
Normal file
108
sdk/examples/forgejo-runner/forgejo-runner-deployment.yaml
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: forgejo-runner-test-tcp
|
||||
labels:
|
||||
app: forgejo-runner-test
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
ports:
|
||||
- name: tcp80
|
||||
protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
selector:
|
||||
app: forgejo-runner-test
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: forgejo-runner-test
|
||||
name: forgejo-runner-test
|
||||
#namespace: gitea
|
||||
spec:
|
||||
# Two replicas means that if one is busy, the other can pick up jobs.
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: forgejo-runner-test
|
||||
strategy: {}
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: forgejo-runner-test
|
||||
spec:
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: docker-certs
|
||||
emptyDir: {}
|
||||
- name: runner-data
|
||||
emptyDir: {}
|
||||
# Initialise our configuration file using offline registration
|
||||
# https://forgejo.org/docs/v1.21/admin/actions/#offline-registration
|
||||
initContainers:
|
||||
- name: runner-register
|
||||
image: code.forgejo.org/forgejo/runner:6.4.0
|
||||
command:
|
||||
- "sh"
|
||||
- "-c"
|
||||
- |
|
||||
forgejo-runner \
|
||||
register \
|
||||
--no-interactive \
|
||||
--token "#####RUNNER_REGISTRATION_TOKEN#####" \
|
||||
--name "edge-test" \
|
||||
--instance "https://garm-provider-test.t09.de" \
|
||||
--labels docker:docker://node:20-bookworm,ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04,ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04
|
||||
volumeMounts:
|
||||
- name: runner-data
|
||||
mountPath: /data
|
||||
containers:
|
||||
- name: runner
|
||||
image: code.forgejo.org/forgejo/runner:6.4.0
|
||||
command:
|
||||
- "sh"
|
||||
- "-c"
|
||||
- |
|
||||
while ! nc -z 127.0.0.1 2376 </dev/null; do
|
||||
echo 'waiting for docker daemon...';
|
||||
sleep 5;
|
||||
done
|
||||
forgejo-runner generate-config > config.yml ;
|
||||
sed -i -e "s|privileged: .*|privileged: true|" config.yml
|
||||
sed -i -e "s|network: .*|network: host|" config.yml ;
|
||||
sed -i -e "s|^ envs:$$| envs:\n DOCKER_HOST: tcp://127.0.0.1:2376\n DOCKER_TLS_VERIFY: 1\n DOCKER_CERT_PATH: /certs/client|" config.yml ;
|
||||
sed -i -e "s|^ options:| options: -v /certs/client:/certs/client|" config.yml ;
|
||||
sed -i -e "s| valid_volumes: \[\]$$| valid_volumes:\n - /certs/client|" config.yml ;
|
||||
/bin/forgejo-runner --config config.yml daemon
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: true
|
||||
privileged: true
|
||||
readOnlyRootFilesystem: false
|
||||
runAsGroup: 0
|
||||
runAsNonRoot: false
|
||||
runAsUser: 0
|
||||
env:
|
||||
- name: DOCKER_HOST
|
||||
value: tcp://localhost:2376
|
||||
- name: DOCKER_CERT_PATH
|
||||
value: /certs/client
|
||||
- name: DOCKER_TLS_VERIFY
|
||||
value: "1"
|
||||
volumeMounts:
|
||||
- name: docker-certs
|
||||
mountPath: /certs
|
||||
- name: runner-data
|
||||
mountPath: /data
|
||||
- name: daemon
|
||||
image: docker:28.0.4-dind
|
||||
env:
|
||||
- name: DOCKER_TLS_CERTDIR
|
||||
value: /certs
|
||||
securityContext:
|
||||
privileged: true
|
||||
volumeMounts:
|
||||
- name: docker-certs
|
||||
mountPath: /certs
|
||||
29
sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v1.yaml
Normal file
29
sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v1.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Is there a swagger file for the new EdgeConnect API?
|
||||
# How does it differ from the EdgeXR API?
|
||||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "edge-ubuntu-buildkit" # name could be used for appName
|
||||
appVersion: "1.0.0"
|
||||
organization: "edp2"
|
||||
spec:
|
||||
# dockerApp: # Docker is OBSOLETE
|
||||
# appVersion: "1.0.0"
|
||||
# manifestFile: "./docker-compose.yaml"
|
||||
# image: "https://registry-1.docker.io/library/nginx:latest"
|
||||
k8sApp:
|
||||
manifestFile: "./k8s-deployment.yaml"
|
||||
infraTemplate:
|
||||
- region: "EU"
|
||||
cloudletOrg: "TelekomOP"
|
||||
cloudletName: "Munich"
|
||||
flavorName: "EU.small"
|
||||
network:
|
||||
outboundConnections:
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 80
|
||||
portRangeMax: 80
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 443
|
||||
portRangeMax: 443
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
29
sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v2.yaml
Normal file
29
sdk/examples/ubuntu-buildkit/EdgeConnectConfig_v2.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Is there a swagger file for the new EdgeConnect API?
|
||||
# How does it differ from the EdgeXR API?
|
||||
kind: edgeconnect-deployment
|
||||
metadata:
|
||||
name: "edge-ubuntu-buildkit" # name could be used for appName
|
||||
appVersion: "1"
|
||||
organization: "edp2-orca"
|
||||
spec:
|
||||
# dockerApp: # Docker is OBSOLETE
|
||||
# appVersion: "1.0.0"
|
||||
# manifestFile: "./docker-compose.yaml"
|
||||
# image: "https://registry-1.docker.io/library/nginx:latest"
|
||||
k8sApp:
|
||||
manifestFile: "./k8s-deployment.yaml"
|
||||
infraTemplate:
|
||||
- region: "US"
|
||||
cloudletOrg: "TelekomOp"
|
||||
cloudletName: "gardener-shepherd-test"
|
||||
flavorName: "defualt"
|
||||
network:
|
||||
outboundConnections:
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 80
|
||||
portRangeMax: 80
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
- protocol: "tcp"
|
||||
portRangeMin: 443
|
||||
portRangeMax: 443
|
||||
remoteCIDR: "0.0.0.0/0"
|
||||
57
sdk/examples/ubuntu-buildkit/k8s-deployment.yaml
Normal file
57
sdk/examples/ubuntu-buildkit/k8s-deployment.yaml
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Add remote buildx builder:
|
||||
# docker buildx create --use --name sidecar tcp://127.0.0.1:1234
|
||||
|
||||
# Run build:
|
||||
# docker buildx build .
|
||||
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ubuntu-runner
|
||||
labels:
|
||||
run: ubuntu-runner
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
ports:
|
||||
- name: tcp80
|
||||
protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
selector:
|
||||
run: ubuntu-runner
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: ubuntu-runner
|
||||
name: ubuntu-runner
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ubuntu-runner
|
||||
strategy: {}
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: ubuntu-runner
|
||||
annotations:
|
||||
container.apparmor.security.beta.kubernetes.io/buildkitd: unconfined
|
||||
spec:
|
||||
containers:
|
||||
- name: ubuntu
|
||||
image: edp.buildth.ing/devfw-cicd/catthehacker/ubuntu:act-22.04-amd64
|
||||
command:
|
||||
- sleep
|
||||
- 7d
|
||||
- args:
|
||||
- --allow-insecure-entitlement=network.host
|
||||
- --oci-worker-no-process-sandbox
|
||||
- --addr
|
||||
- tcp://127.0.0.1:1234
|
||||
image: moby/buildkit:v0.25.1-rootless
|
||||
imagePullPolicy: IfNotPresent
|
||||
name: buildkitd
|
||||
|
|
@ -98,10 +98,12 @@ func NewTransport(opts RetryOptions, auth AuthProvider, logger Logger) *Transpor
|
|||
// Call executes an HTTP request with retry logic and returns typed response
|
||||
func (t *Transport) Call(ctx context.Context, method, url string, body interface{}) (*http.Response, error) {
|
||||
var reqBody io.Reader
|
||||
var jsonData []byte
|
||||
|
||||
// Marshal request body if provided
|
||||
if body != nil {
|
||||
jsonData, err := json.Marshal(body)
|
||||
var err error
|
||||
jsonData, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
|
@ -127,8 +129,16 @@ func (t *Transport) Call(ctx context.Context, method, url string, body interface
|
|||
|
||||
// Log request
|
||||
if t.logger != nil {
|
||||
t.logger.Printf("HTTP %s %s", method, url)
|
||||
t.logger.Printf("BODY %s", reqBody)
|
||||
t.logger.Printf("=== HTTP REQUEST ===")
|
||||
t.logger.Printf("%s %s", method, url)
|
||||
if len(jsonData) > 0 {
|
||||
var prettyJSON bytes.Buffer
|
||||
if err := json.Indent(&prettyJSON, jsonData, "", " "); err == nil {
|
||||
t.logger.Printf("Request Body:\n%s", prettyJSON.String())
|
||||
} else {
|
||||
t.logger.Printf("Request Body: %s", string(jsonData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute request
|
||||
|
|
@ -139,7 +149,8 @@ func (t *Transport) Call(ctx context.Context, method, url string, body interface
|
|||
|
||||
// Log response
|
||||
if t.logger != nil {
|
||||
t.logger.Printf("HTTP %s %s -> %d", method, url, resp.StatusCode)
|
||||
t.logger.Printf("=== HTTP RESPONSE ===")
|
||||
t.logger.Printf("%s %s -> %d", method, url, resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
|
|
@ -151,7 +162,9 @@ func (t *Transport) CallJSON(ctx context.Context, method, url string, body inter
|
|||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue