Skip to content

Commit b66d279

Browse files
Mpt-4856 Add Cloud Account (#26)
1 parent e07247d commit b66d279

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1194
-168
lines changed
File renamed without changes.
File renamed without changes.

app/api/cloud_account/api.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import logging
2+
3+
from fastapi import APIRouter, Depends
4+
from fastapi import status as http_status
5+
from starlette.responses import JSONResponse
6+
7+
from app.api.cloud_account.cloud_accounts_manager import (
8+
CloudStrategyConfiguration,
9+
CloudStrategyManager,
10+
)
11+
from app.api.cloud_account.model import AddCloudAccount, AddCloudAccountResponse
12+
from app.api.invitations.api import get_bearer_token
13+
from app.core.exceptions import (
14+
CloudAccountConfigError,
15+
OptScaleAPIResponseError,
16+
format_error_response,
17+
)
18+
19+
logger = logging.getLogger("api.organizations")
20+
router = APIRouter()
21+
22+
23+
@router.post(
24+
path="",
25+
status_code=http_status.HTTP_201_CREATED,
26+
response_model=AddCloudAccountResponse,
27+
response_model_exclude_none=True,
28+
)
29+
async def add_cloud_account(
30+
data: AddCloudAccount, user_access_token: str = Depends(get_bearer_token)
31+
):
32+
# Here the config as received is validated
33+
cloud_account_config = CloudStrategyConfiguration(
34+
name=data.name,
35+
provider_type=data.type,
36+
config=data.config,
37+
process_recommendations=data.process_recommendations,
38+
auto_import=data.auto_import,
39+
)
40+
try:
41+
# let's select the correct strategy for the given cloud account
42+
cloud_account_strategy = cloud_account_config.select_strategy()
43+
strategy_manager = CloudStrategyManager(strategy=cloud_account_strategy)
44+
# here the conf will be processed in order to use the OptScale API
45+
response = await strategy_manager.add_cloud_account(
46+
config=cloud_account_config,
47+
org_id=data.org_id,
48+
user_access_token=user_access_token,
49+
)
50+
51+
return JSONResponse(
52+
status_code=response.get("status_code", http_status.HTTP_201_CREATED),
53+
content=response.get("data", {}),
54+
)
55+
except (OptScaleAPIResponseError, CloudAccountConfigError, ValueError) as error:
56+
logger.error(f"An error occurred adding the cloud account {data.type} {error}")
57+
return format_error_response(error)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import logging
2+
3+
from app.api.cloud_account.cloud_accounts_conf.cloud_config_strategy import (
4+
CloudConfigStrategy,
5+
)
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
class AWSConfigStrategy(CloudConfigStrategy):
11+
def required_fields(self) -> list:
12+
return ["access_key_id", "secret_access_key"]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import logging
2+
3+
from app.api.cloud_account.cloud_accounts_conf.cloud_config_strategy import (
4+
CloudConfigStrategy,
5+
)
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
class AzureCNRConfigStrategy(CloudConfigStrategy):
11+
def required_fields(self) -> list:
12+
return ["subscription_id", "client_id", "tenant", "secret"]
13+
14+
15+
class AzureTenantConfigStrategy(CloudConfigStrategy):
16+
def required_fields(self) -> list:
17+
return ["client_id", "tenant", "secret"]
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import logging
2+
from abc import ABC, abstractmethod
3+
4+
from app.core.exceptions import CloudAccountConfigError
5+
from app.optscale_api.cloud_accounts import OptScaleCloudAccountAPI
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
class CloudConfigStrategy(ABC):
11+
def __init__(self, optscale_cloud_account_api: OptScaleCloudAccountAPI):
12+
self.optscale_cloud_account_api = optscale_cloud_account_api
13+
14+
@abstractmethod
15+
def required_fields(self) -> list:
16+
pass
17+
18+
def validate_config(self, config: dict):
19+
for field in self.required_fields():
20+
if field not in config:
21+
logger.error(f"The {field} is required in the configuration")
22+
raise CloudAccountConfigError(f"The {field} is required ")
23+
24+
async def link_cloud_account_to_org(
25+
self,
26+
config: dict[str, str],
27+
org_id: str,
28+
user_access_token: str,
29+
): # noqa: E501
30+
"""
31+
This method acts as a wrapper for the OptScale API link_cloud_account_with_org method
32+
:param config: The Cloud Account payload
33+
:param org_id: The user's ORG to link the Cloud Account with
34+
:param user_access_token: The user's access token
35+
:return: The response as it comes from OptScale
36+
:raise: Rethrow OptScaleAPIResponseError if an error occurred consuming the
37+
OptScale APIs.
38+
"""
39+
response = await self.optscale_cloud_account_api.link_cloud_account_with_org(
40+
user_access_token=user_access_token, # noqa: E501
41+
org_id=org_id,
42+
conf=config,
43+
)
44+
cloud_account_type = config.get("type")
45+
logger.info(
46+
f"The Cloud Account {cloud_account_type} has been added to the org {org_id}"
47+
)
48+
return response
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import logging
2+
3+
from app.api.cloud_account.cloud_accounts_conf.cloud_config_strategy import (
4+
CloudConfigStrategy,
5+
)
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
class GCPCNRConfigStrategy(CloudConfigStrategy):
11+
def required_fields(self) -> list:
12+
return ["credentials", "billing_data"]
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
from fastapi import status as http_status
6+
7+
from app.api.cloud_account.cloud_accounts_conf.aws import AWSConfigStrategy
8+
from app.api.cloud_account.cloud_accounts_conf.azure import (
9+
AzureCNRConfigStrategy,
10+
AzureTenantConfigStrategy,
11+
)
12+
from app.api.cloud_account.cloud_accounts_conf.cloud_config_strategy import (
13+
CloudConfigStrategy,
14+
)
15+
from app.api.cloud_account.cloud_accounts_conf.gcp import GCPCNRConfigStrategy
16+
from app.core.exceptions import (
17+
CloudAccountConfigError,
18+
OptScaleAPIResponseError,
19+
)
20+
from app.optscale_api.cloud_accounts import OptScaleCloudAccountAPI
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
class CloudStrategyConfiguration:
26+
ALLOWED_PROVIDERS = {
27+
"aws_cnr": AWSConfigStrategy,
28+
"gcp_cnr": GCPCNRConfigStrategy,
29+
"azure_cnr": AzureCNRConfigStrategy,
30+
"azure_tenant": AzureTenantConfigStrategy,
31+
}
32+
33+
def __init__(
34+
self,
35+
name: str,
36+
provider_type: str,
37+
auto_import: bool = True,
38+
process_recommendations: bool = True, # noqa: E501
39+
config: dict = None,
40+
):
41+
self.name = name
42+
self.type = provider_type
43+
self.config = config if config else {}
44+
self.auto_import = auto_import
45+
self.process_recommendations = process_recommendations
46+
47+
def select_strategy(self):
48+
"""
49+
This method checks if the Cloud Account type is allowed.
50+
If it's valid, an instance of the Cloud Account Class's Strategy
51+
will be created and validated.
52+
:return:
53+
:rtype:
54+
"""
55+
if self.type not in self.ALLOWED_PROVIDERS:
56+
raise OptScaleAPIResponseError(
57+
title="Wrong Cloud Account",
58+
error_code="OE0436",
59+
reason=f"{self.type} is not supported",
60+
params=[f"{self.type}"],
61+
status_code=http_status.HTTP_403_FORBIDDEN,
62+
)
63+
64+
strategy_class = self.ALLOWED_PROVIDERS[self.type]
65+
strategy = strategy_class(optscale_cloud_account_api=OptScaleCloudAccountAPI())
66+
strategy.validate_config(config=self.config)
67+
cloud_account_type = self.config.get("type")
68+
logger.info(f"Cloud Account Conf for {cloud_account_type} has been validated")
69+
return strategy
70+
71+
72+
async def build_payload_dict(
73+
config: CloudStrategyConfiguration, required_fields: dict | None = None
74+
):
75+
"""
76+
This function builds the configuration dict that will be used
77+
as payload for linking a given Cloud Account with a user organization.
78+
:param required_fields: Optional. The required fields to be available in order to build
79+
the conf.
80+
:param config: An instance of CloudStrategyConfiguration with the fields to process
81+
:return: two dictionaries. One is the datasource_conf with the expected fields
82+
{ 'auto_import': False,
83+
'config': {'access_key_id': 'ciao', 'secret_access_key': 'cckkckdkkdskd'}, 'name': 'Test',
84+
'process_recommendations': False, 'type': 'aws_cnr'}
85+
The second one is needed to check if there are missing fields. If the missing_fields is
86+
not empty, it means that one or more of the required fields are missing.
87+
88+
"""
89+
if required_fields is None: # pragma: no cover
90+
required_fields = {
91+
"name",
92+
"type",
93+
"config",
94+
"auto_import",
95+
"process_recommendations",
96+
}
97+
cloud_account_payload = {
98+
key: value for key, value in config.__dict__.items() if key in required_fields
99+
}
100+
# Ensure all required fields are present
101+
missing_fields = required_fields - cloud_account_payload.keys()
102+
return cloud_account_payload, missing_fields
103+
104+
105+
class CloudStrategyManager:
106+
def __init__(self, strategy: CloudConfigStrategy):
107+
self.strategy = strategy
108+
109+
async def add_cloud_account(
110+
self, config: CloudStrategyConfiguration, org_id: str, user_access_token: str
111+
):
112+
"""
113+
Link the given Cloud Account Configuration with
114+
the user's organization.
115+
116+
:param config: An instance of CloudStrategyConfiguration with the chosen Cloud Account
117+
configuration
118+
:param org_id: The user's organization ID to be linked with the Cloud Account.
119+
:param user_access_token: The user's access token
120+
:return: If the datasource is created, a dict like this one will be returned
121+
{
122+
"deleted_at": 0,
123+
"id": "8e8501fa-403a-477b-bd6f-e7569f277f54",
124+
"created_at": 1736349441,
125+
"name": "Test2",
126+
"type": "azure_tenant",
127+
"config": {
128+
"client_id": "my_client_id",
129+
"tenant": "my_tenant_id"
130+
},
131+
"organization_id": "my_org_id",
132+
"auto_import": false,
133+
"import_period": 1,
134+
"last_import_at": 0,
135+
"last_import_modified_at": 0,
136+
"account_id": "my_account_id",
137+
"process_recommendations": false,
138+
"last_import_attempt_at": 0,
139+
"last_import_attempt_error": null,
140+
"last_getting_metrics_at": 0,
141+
"last_getting_metric_attempt_at": 0,
142+
"last_getting_metric_attempt_error": null,
143+
"cleaned_at": 0,
144+
"parent_id": null
145+
}
146+
raises: ValueError if the previously built CloudStrategyConfiguration is tampered.
147+
Rethrow OptScaleAPIResponseError if an error occurred during the communication with the
148+
OptScale API.
149+
"""
150+
if not isinstance(config, CloudStrategyConfiguration):
151+
raise CloudAccountConfigError
152+
153+
cloud_account_payload, missing_fields = await build_payload_dict(config)
154+
if missing_fields:
155+
logger.error(
156+
"Something has been altered in the CloudStrategyConfiguration."
157+
"There are missing required fields in the Cloud Account Conf: {missing_fields}"
158+
) # noqa: E501
159+
raise ValueError(
160+
f"Missing required fields in the Cloud Account Conf: {missing_fields}"
161+
)
162+
163+
response = await self.strategy.link_cloud_account_to_org(
164+
config=cloud_account_payload,
165+
org_id=org_id,
166+
user_access_token=user_access_token,
167+
)
168+
return response

app/api/cloud_account/model.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from pydantic import BaseModel, Field
6+
7+
8+
class AddCloudAccount(BaseModel):
9+
name: str
10+
type: str
11+
config: dict[str, Any]
12+
auto_import: bool = Field(default=True)
13+
process_recommendations: bool = Field(default=True)
14+
org_id: str
15+
model_config = {
16+
"json_schema_extra": {
17+
"examples": [
18+
{
19+
"name": "AWS HQ",
20+
"type": "aws_cnr",
21+
"config": {
22+
"bucket_name": "opt_bucket",
23+
"access_key_id": "key_id",
24+
"secret_access_key": "secret",
25+
},
26+
"auto_import": False,
27+
"process_recommendations": False,
28+
}
29+
]
30+
}
31+
}
32+
33+
34+
class AddCloudAccountResponse(BaseModel):
35+
name: str
36+
type: str
37+
model_config = {
38+
"json_schema_extra": {
39+
"examples": [
40+
{
41+
"name": "AWS HQ",
42+
"type": "aws_cnr",
43+
"config": {
44+
"bucket_name": "opt_bucket",
45+
"access_key_id": "key_id",
46+
"secret_access_key": "secret",
47+
},
48+
"auto_import": True,
49+
"process_recommendations": True,
50+
}
51+
]
52+
}
53+
}

0 commit comments

Comments
 (0)