Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create instances using custom configurations #4886

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deployability/launchers/allocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def parse_arguments():
parser.add_argument("--composite-name", required=False, default=None)
parser.add_argument("--action", choices=['create', 'delete'], required=False, default='create')
parser.add_argument("--custom-credentials", required=False, default=None)
parser.add_argument("--custom-provider-config", required=False, default=None)
parser.add_argument("--track-output", required=False, default='/tmp/wazuh-qa/track.yml')
parser.add_argument("--inventory-output", required=False, default='/tmp/wazuh-qa/inventory.yml')
parser.add_argument("--working-dir", required=False, default='/tmp/wazuh-qa')
Expand Down
31 changes: 27 additions & 4 deletions deployability/modules/allocation/allocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
from pathlib import Path

from .generic import Instance, Provider, models
from .aws.provider import AWSProvider
from .vagrant.provider import VagrantProvider
from .aws.provider import AWSProvider, AWSConfig
from .vagrant.provider import VagrantProvider, VagrantConfig


PROVIDERS = {'vagrant': VagrantProvider, 'aws': AWSProvider}
CONFIGS = {'vagrant': VagrantConfig, 'aws': AWSConfig}


class Allocator:
Expand Down Expand Up @@ -39,12 +41,13 @@ def __create(cls, payload: models.CreationPayload):

Args:
payload (CreationPayload): The payload containing the parameters
for instance creation.
for instance creation.
"""
instance_params = models.CreationPayload(**dict(payload))
provider: Provider = PROVIDERS[payload.provider]()
config = cls.___get_custom_config(payload)
instance = provider.create_instance(
payload.working_dir, instance_params)
payload.working_dir, instance_params, config)
print(f"Instance {instance.identifier} created.")
# Start the instance
instance.start()
Expand All @@ -70,6 +73,26 @@ def __delete(cls, payload: models.DeletionPayload) -> None:
provider.destroy_instance(track.instance_dir, track.identifier)
print(f"Instance {track.identifier} deleted.")

@staticmethod
def ___get_custom_config(payload: models.CreationPayload) -> models.ProviderConfig | None:
"""
Gets the custom configuration from a file.

Args:
payload (CreationPayload): The payload containing the parameters
for instance creation.

Returns:
ProviderConfig: The configuration object.
"""
if not payload.custom_provider_config:
return None
# Read the custom config file and validate it.
config_model: models.ProviderConfig = CONFIGS[payload.provider]
with open(payload.custom_provider_config, 'r') as f:
config = config_model(**yaml.safe_load(f))
return config

@staticmethod
def __generate_inventory(instance: Instance, inventory_path: Path) -> None:
"""
Expand Down
56 changes: 40 additions & 16 deletions deployability/modules/allocation/aws/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,40 +20,43 @@ class AWSProvider(Provider):
"""

provider_name = 'aws'
_client = boto3.resource('ec2')

@classmethod
def _create_instance(cls, base_dir: Path, params: CreationPayload) -> AWSInstance:
def _create_instance(cls, base_dir: Path, params: CreationPayload, config: AWSConfig = None) -> AWSInstance:
"""
Create an AWS EC2 instance.

Args:
base_dir (Path): Base directory for storing instance data.
params (CreationPayload): Payload containing creation parameters.
config (AWSConfig, optional): Configuration for the instance. Defaults to None.

Returns:
AWSInstance: Created AWSInstance object.
"""
temp_id = cls._generate_instance_id(cls.provider_name)
temp_dir = base_dir / temp_id
# Generate the credentials.
credentials = AWSCredentials()
credentials.generate(temp_dir, temp_id.split('-')[-1] + '_key')
# Parse the config and create the AWS EC2 instance.
config = cls._parse_config(params, credentials)
_instance = cls._client.create_instances(ImageId=config.ami,
InstanceType=config.type,
KeyName=config.key_name,
SecurityGroupIds=config.security_groups,
MinCount=1, MaxCount=1)[0]
# Wait until the instance is running.
_instance.wait_until_running()
if not config:
# Generate the credentials.
credentials.generate(temp_dir, temp_id.split('-')[-1] + '_key')
# Parse the config if it is not provided.
config = cls.__parse_config(params, credentials)
else:
# Load the existing credentials.
credentials.load(config.key_name)
# Create the temp directory.
# TODO: Review this on the credentials refactor.
if not temp_dir.exists():
temp_dir.mkdir(parents=True, exist_ok=True)
# Generate the instance.
instance_id = cls.__create_ec2_instance(config)
# Rename the temp directory to its real name.
instance_dir = Path(base_dir, _instance.instance_id)
instance_dir = Path(base_dir, instance_id)
os.rename(temp_dir, instance_dir)
credentials.key_path = (instance_dir / credentials.name).with_suffix('.pem')

return AWSInstance(instance_dir, _instance.instance_id, credentials, config.user)
return AWSInstance(instance_dir, instance_id, credentials, config.user)

@staticmethod
def _load_instance(instance_dir: Path, instance_id: str) -> AWSInstance:
Expand Down Expand Up @@ -83,8 +86,29 @@ def _destroy_instance(cls, instance_dir: str, identifier: str) -> None:
instance.credentials.delete()
instance.delete()

@staticmethod
def __create_ec2_instance(config: AWSConfig) -> str:
"""
Create an AWS EC2 instance.

Args:
config (AWSConfig): Configuration for the instance.

Returns:
str: Identifier of the created instance.
"""
client = boto3.resource('ec2')
instance = client.create_instances(ImageId=config.ami,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be always 1 host?
Otherwise, [0] should be assigned by a variable

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the config is always "plain", in the Allocator class the function ___get_custom_config() validates the config comes with the correct format. In the case of a VagrantConfig, the config should be something like this

ip: '192.168.57.3'
cpu: 1
memory: 2048
box: 'generic/ubuntu2004'
box_version: '4.3.8'
public_key: '/tmp/wazuh-qa/VAGRANT-1179DAFB-EFDD-47BA-B97F-5ECC8B3428B7/instance_key.pub'

If the file has a list of hosts or something different, it will raise an error that the data on the file doesn't match with the model

InstanceType=config.type,
KeyName=config.key_name,
SecurityGroupIds=config.security_groups,
MinCount=1, MaxCount=1)[0]
# Wait until the instance is running.
instance.wait_until_running()
return instance.instance_id

@classmethod
def _parse_config(cls, params: CreationPayload, credentials: AWSCredentials) -> AWSConfig:
def __parse_config(cls, params: CreationPayload, credentials: AWSCredentials) -> AWSConfig:
"""
Parse configuration parameters for creating an AWS EC2 instance.

Expand Down
49 changes: 33 additions & 16 deletions deployability/modules/allocation/generic/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pathlib import Path
from pydantic import BaseModel, IPvAnyAddress, field_validator
from pydantic import BaseModel, IPvAnyAddress, field_validator, model_validator
from typing_extensions import Literal


Expand Down Expand Up @@ -35,32 +35,49 @@ class TrackOutput(BaseModel):

class InputPayload(BaseModel):
action: Literal['create', 'delete', 'status'] = 'create'
provider: str | None
size: Literal['micro', 'small', 'medium', 'large', None]
composite_name: str | None
track_output: Path | None
inventory_output: Path | None
working_dir: Path | None
custom_credentials: str | None
provider: str | None = None
size: Literal['micro', 'small', 'medium', 'large', None] = None
composite_name: str | None = None
working_dir: Path | None = Path('/tmp/wazuh-qa')
track_output: Path | None = working_dir / 'track.yml'
inventory_output: Path | None = working_dir / 'inventory.yml'
custom_credentials: str | None = None
custom_provider_config: Path | None = None


class CreationPayload(InputPayload):
provider: str
size: Literal['micro', 'small', 'medium', 'large']
composite_name: str
size: Literal['micro', 'small', 'medium', 'large'] | None = None
composite_name: str | None = None
track_output: Path
inventory_output: Path
working_dir: Path
custom_credentials: str | None = None

@field_validator('custom_credentials')
custom_provider_config: Path | None = None

@model_validator(mode='before')
def validate_dependency(cls, values) -> dict:
"""Validate required fields."""
required_if_not_config = ['composite_name', 'size']
if values.get('custom_provider_config'):
return values
for attr in required_if_not_config:
if not values.get(attr):
raise ValueError(f"{attr} is required if custom_provider_config is not provided.")

return values

@field_validator('custom_provider_config')
@classmethod
def check_credentials(cls, v: str) -> str | None:
def check_config(cls, v: Path | None) -> Path | None:
if not v:
return None
path = Path(v)
if not path.exists() or not path.is_file():
raise ValueError(f"Invalid credentials path: {path}")
if not v.exists():
raise ValueError(f"Custom provider config file does not exist: {v}")
elif not v.is_file():
raise ValueError(f"Custom provider config file is not a file: {v}")
elif not v.suffix in ['.yml', '.yaml']:
raise ValueError(f"Custom provider config file must be yaml: {v}")
return v

@field_validator('working_dir', mode='before')
Expand Down
10 changes: 6 additions & 4 deletions deployability/modules/allocation/generic/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pathlib import Path

from .instance import Instance
from .models import CreationPayload
from .models import CreationPayload, ProviderConfig


class Provider(ABC):
Expand Down Expand Up @@ -47,20 +47,21 @@ def provider_name(self) -> str:
pass

@classmethod
def create_instance(cls, base_dir: str | Path, params: CreationPayload) -> Instance:
def create_instance(cls, base_dir: str | Path, params: CreationPayload, config: ProviderConfig = None) -> Instance:
"""
Creates a new instance.

Args:
base_dir (str | Path): The base directory for the instance.
params (CreationPayload): The parameters for creating the instance.
config (ProviderConfig, optional): The configuration for the instance. Defaults to None.

Returns:
Instance: The created instance.
"""
params = CreationPayload(**dict(params))
base_dir = Path(base_dir)
return cls._create_instance(base_dir, params)
return cls._create_instance(base_dir, params, config)

@classmethod
def load_instance(cls, instance_dir: str | Path, instance_id: str) -> Instance:
Expand Down Expand Up @@ -97,13 +98,14 @@ def destroy_instance(cls, instance_dir: str | Path, identifier: str) -> None:

@classmethod
@abstractmethod
def _create_instance(cls, base_dir: Path, params: CreationPayload) -> Instance:
def _create_instance(cls, base_dir: Path, params: CreationPayload, config: ProviderConfig = None) -> Instance:
"""
Abstract method that creates a new instance.

Args:
base_dir (Path): The base directory for the instance.
params (CreationPayload): The parameters for creating the instance.
config (ProviderConfig, optional): The configuration for the instance. Defaults to None.

Returns:
Instance: The created instance.
Expand Down
20 changes: 6 additions & 14 deletions deployability/modules/allocation/vagrant/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,30 +71,22 @@ def generate(self, base_dir: str | Path, name: str) -> Path:
self.key_path = private_key_path
return self.key_path

def load(self, base_dir: str | Path, name: str) -> None:
def load(self, path: str | Path) -> None:
"""
Loads an existing key pair from the specified directory.

Args:
base_dir (str | Path): The directory where the key pair is stored.
name (str): The filename of the key pair.
path (str | Path): The path to the key pair.

Raises:
CredentialsError: This exception is raised if the key pair doesn't exist or the specified directory is invalid.
"""
base_dir = Path(base_dir)
if not base_dir.exists() or not base_dir.is_dir():
raise self.CredentialsError(f"Invalid path {base_dir}.")
key_path = Path(base_dir, name)
pub_key_path = key_path.with_suffix(".pub")
key_path = Path(path)
if not key_path.exists() or not key_path.is_file():
raise self.CredentialsError(f"Invalid key name {name}.")
if not pub_key_path.exists() or not pub_key_path.is_file():
raise self.CredentialsError(f"Non-existen public key for {name}.")
# Save instance attributes.
raise self.CredentialsError(f"Invalid path {key_path}.")
self.key_path = key_path
self.name = name
self.key_id = name
self.name = key_path.name
self.key_id = key_path.name

def delete(self) -> None:
"""
Expand Down
15 changes: 10 additions & 5 deletions deployability/modules/allocation/vagrant/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ class VagrantProvider(Provider):
provider_name = 'vagrant'

@classmethod
def _create_instance(cls, base_dir: Path, params: CreationPayload) -> VagrantInstance:
def _create_instance(cls, base_dir: Path, params: CreationPayload, config: VagrantConfig = None) -> VagrantInstance:
"""
Creates a Vagrant instance.

Args:
base_dir (Path): The base directory for the instance.
params (CreationPayload): The parameters for instance creation.
config (VagrantConfig, optional): The configuration for the instance. Defaults to None.

Returns:
VagrantInstance: The created Vagrant instance.
Expand All @@ -37,11 +38,15 @@ def _create_instance(cls, base_dir: Path, params: CreationPayload) -> VagrantIns
# Create the instance directory.
instance_dir = base_dir / instance_id
instance_dir.mkdir(parents=True, exist_ok=True)
# Generate the credentials.
credentials = VagrantCredentials()
credentials.generate(instance_dir, 'instance_key')
# Parse the config and create Vagrantfile.
config = cls.__parse_config(params, credentials)
if not config:
# Generate the credentials.
credentials.generate(instance_dir, 'instance_key')
# Parse the config if it is not provided.
config = cls.__parse_config(params, credentials)
else:
credentials.load(config.public_key)
# Create the Vagrantfile.
cls.__create_vagrantfile(instance_dir, config)
return VagrantInstance(instance_dir, instance_id, credentials)

Expand Down