diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/README.md b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/README.md index 66159b817..ca72584d8 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/README.md +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/README.md @@ -10,6 +10,7 @@ - [Metadata Migration](#metadata-migration) - [Replay](#replay) - [Kafka](#kafka) + - [Client Options](#client-options) - [Usage](#usage) - [Library](#library) - [CLI](#cli) @@ -82,6 +83,8 @@ metadata_migration: kafka: broker_endpoints: "kafka:9092" standard: +client_options: + user_agent_extra: "test-user-agent-v1.0" ``` ## Services.yaml spec @@ -225,13 +228,19 @@ Exactly one of the following blocks must be present: A Kafka cluster is used in the capture and replay stage of the migration to store recorded requests and responses before they're replayed. While it's not necessary for a user to directly interact with the Kafka cluster in most cases, there are a handful of commands that can be helpful for checking on the status or resetting state that are exposed by the Console CLI. -- `broker_endpoints`: required, comma-separated list of kafaka broker endpoints +- `broker_endpoints`: required, comma-separated list of kafka broker endpoints Exactly one of the following keys must be present, but both are nullable (they don't have or need any additional parameters). - `msk`: the Kafka instance is deployed as AWS Managed Service Kafka - `standard`: the Kafka instance is deployed as a standard Kafka cluster (e.g. on Docker) +### Client Options + +Client options are global settings that are applied to different clients used throughout this library + +- `user_agent_extra`: optional, a user agent string that will be appended to the `User-Agent` header of all requests from this library + ## Usage ### Library diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/environment.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/environment.py index 8f2950c39..a544c8a49 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/environment.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/environment.py @@ -8,6 +8,7 @@ from console_link.models.snapshot import Snapshot from console_link.models.replayer_base import Replayer from console_link.models.kafka import Kafka +from console_link.models.client_options import ClientOptions import yaml from cerberus import Validator @@ -25,7 +26,8 @@ "snapshot": {"type": "dict", "required": False}, "metadata_migration": {"type": "dict", "required": False}, "replay": {"type": "dict", "required": False}, - "kafka": {"type": "dict", "required": False} + "kafka": {"type": "dict", "required": False}, + "client_options": {"type": "dict", "required": False}, } @@ -38,6 +40,7 @@ class Environment: metadata: Optional[Metadata] = None replay: Optional[Replayer] = None kafka: Optional[Kafka] = None + client_options: Optional[ClientOptions] = None def __init__(self, config_file: str): logger.info(f"Loading config file: {config_file}") @@ -50,8 +53,12 @@ def __init__(self, config_file: str): logger.error(f"Config file validation errors: {v.errors}") raise ValueError("Invalid config file", v.errors) + if 'client_options' in self.config: + self.client_options: ClientOptions = ClientOptions(self.config["client_options"]) + if 'source_cluster' in self.config: - self.source_cluster = Cluster(self.config["source_cluster"]) + self.source_cluster = Cluster(config=self.config["source_cluster"], + client_options=self.client_options) logger.info(f"Source cluster initialized: {self.source_cluster.endpoint}") else: logger.info("No source cluster provided") @@ -59,14 +66,16 @@ def __init__(self, config_file: str): # At some point, target and replayers should be stored as pairs, but for the time being # we can probably assume one target cluster. if 'target_cluster' in self.config: - self.target_cluster: Cluster = Cluster(self.config["target_cluster"]) + self.target_cluster: Cluster = Cluster(config=self.config["target_cluster"], + client_options=self.client_options) logger.info(f"Target cluster initialized: {self.target_cluster.endpoint}") else: logger.warning("No target cluster provided. This may prevent other actions from proceeding.") if 'metrics_source' in self.config: self.metrics_source: MetricsSource = get_metrics_source( - self.config["metrics_source"] + config=self.config["metrics_source"], + client_options=self.client_options ) logger.info(f"Metrics source initialized: {self.metrics_source}") else: @@ -75,13 +84,14 @@ def __init__(self, config_file: str): if 'backfill' in self.config: self.backfill: Backfill = get_backfill(self.config["backfill"], source_cluster=self.source_cluster, - target_cluster=self.target_cluster) + target_cluster=self.target_cluster, + client_options=self.client_options) logger.info(f"Backfill migration initialized: {self.backfill}") else: logger.info("No backfill provided") if 'replay' in self.config: - self.replay: Replayer = get_replayer(self.config["replay"]) + self.replay: Replayer = get_replayer(self.config["replay"], client_options=self.client_options) logger.info(f"Replay initialized: {self.replay}") if 'snapshot' in self.config: diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/backfill_osi.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/backfill_osi.py index 3ebbe7d2c..17aa3f8b1 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/backfill_osi.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/backfill_osi.py @@ -1,12 +1,13 @@ +from console_link.models.client_options import ClientOptions from console_link.models.osi_utils import (create_pipeline_from_env, start_pipeline, stop_pipeline, OpenSearchIngestionMigrationProps) from console_link.models.cluster import Cluster from console_link.models.backfill_base import Backfill from console_link.models.command_result import CommandResult -from typing import Dict +from typing import Dict, Optional from cerberus import Validator -import boto3 +from console_link.models.utils import create_boto3_client OSI_SCHEMA = { 'pipeline_role_arn': { @@ -61,15 +62,17 @@ class OpenSearchIngestionBackfill(Backfill): A migration manager for an OpenSearch Ingestion pipeline. """ - def __init__(self, config: Dict, source_cluster: Cluster, target_cluster: Cluster) -> None: + def __init__(self, config: Dict, source_cluster: Cluster, target_cluster: Cluster, + client_options: Optional[ClientOptions] = None) -> None: super().__init__(config) + self.client_options = client_options config = config["opensearch_ingestion"] v = Validator(OSI_SCHEMA) if not v.validate(config): raise ValueError("Invalid config file for OpenSearchIngestion migration", v.errors) self.osi_props = OpenSearchIngestionMigrationProps(config=config) - self.osi_client = boto3.client('osis') + self.osi_client = create_boto3_client(aws_service_name='osis', client_options=self.client_options) self.source_cluster = source_cluster self.target_cluster = target_cluster diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/backfill_rfs.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/backfill_rfs.py index 4681fa147..59af018ed 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/backfill_rfs.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/backfill_rfs.py @@ -5,6 +5,7 @@ import requests from console_link.models.backfill_base import Backfill, BackfillStatus +from console_link.models.client_options import ClientOptions from console_link.models.cluster import Cluster from console_link.models.schema_tools import contains_one_of from console_link.models.command_result import CommandResult @@ -87,14 +88,17 @@ def scale(self, units: int, *args, **kwargs) -> CommandResult: class ECSRFSBackfill(RFSBackfill): - def __init__(self, config: Dict, target_cluster: Cluster) -> None: + def __init__(self, config: Dict, target_cluster: Cluster, client_options: Optional[ClientOptions] = None) -> None: super().__init__(config) + self.client_options = client_options self.target_cluster = target_cluster self.default_scale = self.config["reindex_from_snapshot"].get("scale", 1) self.ecs_config = self.config["reindex_from_snapshot"]["ecs"] - self.ecs_client = ECSService(self.ecs_config["cluster_name"], self.ecs_config["service_name"], - self.ecs_config.get("aws_region", None)) + self.ecs_client = ECSService(cluster_name=self.ecs_config["cluster_name"], + service_name=self.ecs_config["service_name"], + aws_region=self.ecs_config.get("aws_region", None), + client_options=self.client_options) def start(self, *args, **kwargs) -> CommandResult: logger.info(f"Starting RFS backfill by setting desired count to {self.default_scale} instances") diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/client_options.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/client_options.py new file mode 100644 index 000000000..e202071da --- /dev/null +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/client_options.py @@ -0,0 +1,30 @@ +from typing import Dict, Optional +import logging +from cerberus import Validator + +logger = logging.getLogger(__name__) + +SCHEMA = { + "client_options": { + "type": "dict", + "schema": { + "user_agent_extra": {"type": "string", "required": False}, + }, + } +} + + +class ClientOptions: + """ + Options that can be configured for boto3 and request library clients. + """ + + user_agent_extra: Optional[str] = None + + def __init__(self, config: Dict) -> None: + logger.info(f"Initializing client options with config: {config}") + v = Validator(SCHEMA) + if not v.validate({'client_options': config}): + raise ValueError("Invalid config file for client options", v.errors) + + self.user_agent_extra = config.get("user_agent_extra", None) diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/cluster.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/cluster.py index c56496530..32bc46531 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/cluster.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/cluster.py @@ -10,7 +10,9 @@ from requests.auth import HTTPBasicAuth from requests_auth_aws_sigv4 import AWSSigV4 +from console_link.models.client_options import ClientOptions from console_link.models.schema_tools import contains_one_of +from console_link.models.utils import create_boto3_client, append_user_agent_header_for_requests requests.packages.urllib3.disable_warnings() # ignore: type @@ -79,8 +81,9 @@ class Cluster: auth_type: Optional[AuthMethod] = None auth_details: Optional[Dict[str, Any]] = None allow_insecure: bool = False + client_options: Optional[ClientOptions] = None - def __init__(self, config: Dict) -> None: + def __init__(self, config: Dict, client_options: Optional[ClientOptions] = None) -> None: logger.info(f"Initializing cluster with config: {config}") v = Validator(SCHEMA) if not v.validate({'cluster': config}): @@ -97,6 +100,7 @@ def __init__(self, config: Dict) -> None: elif 'sigv4' in config: self.auth_type = AuthMethod.SIGV4 self.auth_details = config["sigv4"] if config["sigv4"] is not None else {} + self.client_options = client_options def get_basic_auth_password(self) -> str: """This method will return the basic auth password, if basic auth is enabled. @@ -108,11 +112,11 @@ def get_basic_auth_password(self) -> str: return self.auth_details["password"] # Pull password from AWS Secrets Manager assert "password_from_secret_arn" in self.auth_details # for mypy's sake - client = boto3.client('secretsmanager') + client = create_boto3_client(aws_service_name="secretsmanager", client_options=self.client_options) password = client.get_secret_value(SecretId=self.auth_details["password_from_secret_arn"]) return password["SecretString"] - def _get_sigv4_details(self, force_region=False) -> tuple[str, str]: + def _get_sigv4_details(self, force_region=False) -> tuple[str, Optional[str]]: """Return the service signing name and region name. If force_region is true, it will instantiate a boto3 session to guarantee that the region is not None. This will fail if AWS credentials are not available. @@ -145,9 +149,14 @@ def call_api(self, path, method: HttpMethod = HttpMethod.GET, data=None, headers """ if session is None: session = requests.Session() - + auth = self._generate_auth_object() + request_headers = headers + if self.client_options and self.client_options.user_agent_extra: + user_agent_extra = self.client_options.user_agent_extra + request_headers = append_user_agent_header_for_requests(headers=headers, user_agent_extra=user_agent_extra) + # Extract query parameters from kwargs params = kwargs.get('params', {}) @@ -159,7 +168,7 @@ def call_api(self, path, method: HttpMethod = HttpMethod.GET, data=None, headers params=params, auth=auth, data=data, - headers=headers, + headers=request_headers, timeout=timeout ) logger.info(f"Received response: {r.status_code} {method.name} {self.endpoint}{path} - {r.text[:1000]}") diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/ecs_service.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/ecs_service.py index 3d74e13a1..9d1dff93c 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/ecs_service.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/ecs_service.py @@ -1,11 +1,8 @@ import logging from typing import NamedTuple, Optional -import boto3 - from console_link.models.command_result import CommandResult -from console_link.models.utils import AWSAPIError, raise_for_aws_api_error - +from console_link.models.utils import AWSAPIError, raise_for_aws_api_error, create_boto3_client logger = logging.getLogger(__name__) @@ -20,13 +17,15 @@ def __str__(self): class ECSService: - def __init__(self, cluster_name, service_name, aws_region=None): + def __init__(self, cluster_name, service_name, aws_region=None, client_options=None): self.cluster_name = cluster_name self.service_name = service_name self.aws_region = aws_region + self.client_options = client_options logger.info(f"Creating ECS client for region {aws_region}, if specified") - self.client = boto3.client("ecs", region_name=self.aws_region) + self.client = create_boto3_client(aws_service_name="ecs", region=self.aws_region, + client_options=self.client_options) def set_desired_count(self, desired_count: int) -> CommandResult: logger.info(f"Setting desired count for service {self.service_name} to {desired_count}") @@ -47,7 +46,7 @@ def set_desired_count(self, desired_count: int) -> CommandResult: desired_count = response["service"]["desiredCount"] return CommandResult(True, f"Service {self.service_name} set to {desired_count} desired count." f" Currently {running_count} running and {pending_count} pending.") - + def get_instance_statuses(self) -> Optional[InstanceStatuses]: logger.info(f"Getting instance statuses for service {self.service_name}") response = self.client.describe_services( diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/factories.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/factories.py index 5ea256430..aa17fe96e 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/factories.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/factories.py @@ -1,6 +1,7 @@ from enum import Enum from typing import Dict, Optional +from console_link.models.client_options import ClientOptions from console_link.models.replayer_docker import DockerReplayer from console_link.models.metrics_source import CloudwatchMetricsSource, PrometheusMetricsSource from console_link.models.backfill_base import Backfill @@ -55,9 +56,9 @@ def get_snapshot(config: Dict, source_cluster: Cluster): raise UnsupportedSnapshotError(next(iter(config.keys()))) -def get_replayer(config: Dict): +def get_replayer(config: Dict, client_options: Optional[ClientOptions] = None): if 'ecs' in config: - return ECSReplayer(config) + return ECSReplayer(config=config, client_options=client_options) if 'docker' in config: return DockerReplayer(config) logger.error(f"An unsupported replayer type was provided: {config.keys()}") @@ -74,7 +75,8 @@ def get_kafka(config: Dict): raise UnsupportedKafkaError(', '.join(config.keys())) -def get_backfill(config: Dict, source_cluster: Optional[Cluster], target_cluster: Optional[Cluster]) -> Backfill: +def get_backfill(config: Dict, source_cluster: Optional[Cluster], target_cluster: Optional[Cluster], + client_options: Optional[ClientOptions] = None) -> Backfill: if BackfillType.opensearch_ingestion.name in config: if source_cluster is None: raise ValueError("source_cluster must be provided for OpenSearch Ingestion backfill") @@ -83,7 +85,8 @@ def get_backfill(config: Dict, source_cluster: Optional[Cluster], target_cluster logger.debug("Creating OpenSearch Ingestion backfill instance") return OpenSearchIngestionBackfill(config=config, source_cluster=source_cluster, - target_cluster=target_cluster) + target_cluster=target_cluster, + client_options=client_options) elif BackfillType.reindex_from_snapshot.name in config: if target_cluster is None: raise ValueError("target_cluster must be provided for RFS backfill") @@ -95,17 +98,18 @@ def get_backfill(config: Dict, source_cluster: Optional[Cluster], target_cluster elif 'ecs' in config[BackfillType.reindex_from_snapshot.name]: logger.debug("Creating ECS RFS backfill instance") return ECSRFSBackfill(config=config, - target_cluster=target_cluster) + target_cluster=target_cluster, + client_options=client_options) logger.error(f"An unsupported backfill source type was provided: {config.keys()}") raise UnsupportedBackfillTypeError(', '.join(config.keys())) -def get_metrics_source(config): +def get_metrics_source(config, client_options: Optional[ClientOptions] = None): if 'prometheus' in config: - return PrometheusMetricsSource(config) + return PrometheusMetricsSource(config=config, client_options=client_options) elif 'cloudwatch' in config: - return CloudwatchMetricsSource(config) + return CloudwatchMetricsSource(config=config, client_options=client_options) else: logger.error(f"An unsupported metrics source type was provided: {config.keys()}") raise UnsupportedMetricsSourceError(', '.join(config.keys())) diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/metrics_source.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/metrics_source.py index fccdb9cf4..097865bb5 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/metrics_source.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/metrics_source.py @@ -1,11 +1,10 @@ from datetime import datetime from enum import Enum from typing import Any, Dict, List, Optional, Tuple - -import boto3 -import botocore from cerberus import Validator -from console_link.models.utils import raise_for_aws_api_error +from console_link.models.client_options import ClientOptions +from console_link.models.utils import raise_for_aws_api_error, create_boto3_client, \ + append_user_agent_header_for_requests import requests import logging @@ -112,16 +111,15 @@ def __init__(self, list_metric_data: Dict[str, Any]): class CloudwatchMetricsSource(MetricsSource): - def __init__(self, config: Dict) -> None: + def __init__(self, config: Dict, client_options: Optional[ClientOptions] = None) -> None: super().__init__(config) + self.client_options = client_options logger.info(f"Initializing CloudwatchMetricsSource from config {config}") + self.aws_region = None if type(config["cloudwatch"]) is dict and "aws_region" in config["cloudwatch"]: self.aws_region = config["cloudwatch"]["aws_region"] - self.boto_config = botocore.config.Config(region_name=self.aws_region) - else: - self.aws_region = None - self.boto_config = None - self.client = boto3.client("cloudwatch", config=self.boto_config) + self.client = create_boto3_client(aws_service_name="cloudwatch", region=self.aws_region, + client_options=self.client_options) def get_metrics(self, recent=True) -> Dict[str, List[str]]: logger.info(f"{self.__class__.__name__}.get_metrics called with {recent=}") @@ -203,8 +201,9 @@ def prometheus_component_names(c: Component) -> str: class PrometheusMetricsSource(MetricsSource): - def __init__(self, config: Dict) -> None: + def __init__(self, config: Dict, client_options: Optional[ClientOptions] = None) -> None: super().__init__(config) + self.client_options = client_options logger.info(f"Initializing PrometheusMetricsSource from config {config}") self.endpoint = config["prometheus"]["endpoint"] @@ -216,9 +215,14 @@ def get_metrics(self, recent=False) -> Dict[str, List[str]]: raise NotImplementedError("Recent metrics are not implemented for Prometheus") for c in Component: exported_job = prometheus_component_names(c) + headers = None + if self.client_options and self.client_options.user_agent_extra: + headers = append_user_agent_header_for_requests(headers=None, + user_agent_extra=self.client_options.user_agent_extra) r = requests.get( f"{self.endpoint}/api/v1/query", params={"query": f'{{exported_job="{exported_job}"}}'}, + headers=headers, ) logger.debug(f"Request to Prometheus: {r.request}") logger.debug(f"Response status code: {r.status_code}") @@ -243,6 +247,10 @@ def get_metric_data( f"{start_time=}, {period_in_seconds=}, {end_time=}, {dimensions=}") if not end_time: end_time = datetime.now() + headers = None + if self.client_options and self.client_options.user_agent_extra: + headers = append_user_agent_header_for_requests(headers=None, + user_agent_extra=self.client_options.user_agent_extra) r = requests.get( f"{self.endpoint}/api/v1/query_range", params={ # type: ignore @@ -251,6 +259,7 @@ def get_metric_data( "end": end_time.timestamp(), "step": period_in_seconds, }, + headers=headers, ) logger.debug(f"Request to Prometheus: {r.request}") logger.debug(f"Response status code: {r.status_code}") diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/osi_utils.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/osi_utils.py index 4febbdba3..bb9f5cdcc 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/osi_utils.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/osi_utils.py @@ -284,11 +284,13 @@ def create_pipeline_from_env(osi_client, source_endpoint_clean = sanitize_endpoint(endpoint=source_cluster.endpoint, remove_port=False) # Target endpoints for OSI are not currently allowed a port target_endpoint_clean = sanitize_endpoint(target_cluster.endpoint, True) - + source_auth_secret = None + if source_cluster.auth_details and "password_from_secret_arn" in source_cluster.auth_details: + source_auth_secret = source_cluster.auth_details["password_from_secret_arn"] pipeline_config_string = construct_pipeline_config(pipeline_config_file_path=pipeline_template_path, source_endpoint=source_endpoint_clean, source_auth_type=source_cluster.auth_type, - source_auth_secret=source_cluster.aws_secret_arn, + source_auth_secret=source_auth_secret, target_endpoint=target_endpoint_clean, target_auth_type=target_cluster.auth_type, pipeline_role_arn=osi_props.pipeline_role_arn, diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/replayer_ecs.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/replayer_ecs.py index c4c7fca12..7b132ec80 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/replayer_ecs.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/replayer_ecs.py @@ -1,4 +1,5 @@ -from typing import Dict +from typing import Dict, Optional +from console_link.models.client_options import ClientOptions from console_link.models.command_result import CommandResult from console_link.models.ecs_service import ECSService from console_link.models.replayer_base import Replayer, ReplayStatus @@ -9,11 +10,14 @@ class ECSReplayer(Replayer): - def __init__(self, config: Dict) -> None: + def __init__(self, config: Dict, client_options: Optional[ClientOptions] = None) -> None: super().__init__(config) + self.client_options = client_options self.ecs_config = self.config["ecs"] - self.ecs_client = ECSService(self.ecs_config["cluster_name"], self.ecs_config["service_name"], - self.ecs_config.get("aws_region", None)) + self.ecs_client = ECSService(cluster_name=self.ecs_config["cluster_name"], + service_name=self.ecs_config["service_name"], + aws_region=self.ecs_config.get("aws_region", None), + client_options=self.client_options) def start(self, *args, **kwargs) -> CommandResult: logger.info(f"Starting ECS replayer by setting desired count to {self.default_scale} instances") diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/utils.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/utils.py index ea31fd3a1..a78614248 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/utils.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/console_link/models/utils.py @@ -1,6 +1,11 @@ -# define a custom exception for aws api errors +from botocore import config from enum import Enum -from typing import Dict +from typing import Dict, Optional +from datetime import datetime +import boto3 +import requests.utils + +from console_link.models.client_options import ClientOptions class AWSAPIError(Exception): @@ -26,3 +31,26 @@ def raise_for_aws_api_error(response: Dict) -> None: class ExitCode(Enum): SUCCESS = 0 FAILURE = 1 + + +def generate_log_file_path(topic: str) -> str: + now = datetime.now().isoformat() + return f"{now}-{topic}.log" + + +def create_boto3_client(aws_service_name: str, region: Optional[str] = None, + client_options: Optional[ClientOptions] = None): + client_config = None + if client_options and client_options.user_agent_extra: + user_agent_extra_param = {"user_agent_extra": client_options.user_agent_extra} + client_config = config.Config(**user_agent_extra_param) + return boto3.client(aws_service_name, region_name=region, config=client_config) + + +def append_user_agent_header_for_requests(headers: Optional[dict], user_agent_extra: str): + adjusted_headers = dict(headers) if headers else {} + if "User-Agent" in adjusted_headers: + adjusted_headers["User-Agent"] = f"{adjusted_headers['User-Agent']} {user_agent_extra}" + else: + adjusted_headers["User-Agent"] = f"{requests.utils.default_user_agent()} {user_agent_extra}" + return adjusted_headers diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/data/services_with_client_options.yaml b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/data/services_with_client_options.yaml new file mode 100644 index 000000000..ff3f14d5f --- /dev/null +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/data/services_with_client_options.yaml @@ -0,0 +1,43 @@ +source_cluster: + endpoint: "https://elasticsearch:9200" + allow_insecure: true + basic_auth: + username: "admin" + password: "admin" +target_cluster: + endpoint: "https://opensearchtarget:9200" + allow_insecure: true + basic_auth: + username: "admin" + password: "myStrongPassword123!" +metrics_source: + prometheus: + endpoint: "http://prometheus:9090" +backfill: + reindex_from_snapshot: + ecs: + cluster_name: "migration-cluster" + service_name: "rfs-service" +snapshot: + snapshot_name: "test_snapshot" + fs: + repo_path: "/snapshot/test-console" + otel_endpoint: "http://otel-collector:4317" +metadata_migration: + from_snapshot: # If not provided, these are assumed from the snapshot object + snapshot_name: "snapshot_2023_01_01" + s3: + repo_uri: "s3://my-snapshot-bucket" + aws_region: "us-east-2" + otel_endpoint: "http://otel-collector:4317" + min_replicas: 0 +replay: + ecs: + cluster_name: "my-cluster" + service_name: "my-service" + scale: 2 +kafka: + broker_endpoints: "kafka1:9092,kafka2:9092,kafka3:9092" + standard: +client_options: + user_agent_extra: "test-user-agent-v1.0" diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_client_config.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_client_config.py new file mode 100644 index 000000000..d385c5a50 --- /dev/null +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_client_config.py @@ -0,0 +1,31 @@ +import pytest + +from console_link.models.client_options import ClientOptions + + +def test_valid_client_options_config(): + user_agent = "test_agent_v1.0" + custom_client_config = { + "user_agent_extra": user_agent + } + client_options = ClientOptions(custom_client_config) + + assert isinstance(client_options, ClientOptions) + assert client_options.user_agent_extra == user_agent + + +def test_valid_empty_client_options_config(): + custom_client_config = {} + client_options = ClientOptions(custom_client_config) + + assert isinstance(client_options, ClientOptions) + assert client_options.user_agent_extra is None + + +def test_invalid_client_options_config(): + custom_client_config = { + "agent": "test-agent_v1.0" + } + with pytest.raises(ValueError) as excinfo: + ClientOptions(custom_client_config) + assert "Invalid config file for client options" in excinfo.value.args[0] diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_cluster.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_cluster.py index 1d7bfb7cf..ef0f8e46e 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_cluster.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_cluster.py @@ -6,6 +6,7 @@ import boto3 import console_link.middleware.clusters as clusters_ +from console_link.models.client_options import ClientOptions from console_link.models.cluster import AuthMethod, Cluster from tests.utils import create_valid_cluster @@ -226,6 +227,21 @@ def test_valid_cluster_api_call_with_no_auth(requests_mock): assert response.json() == {'test': True} +def test_valid_cluster_api_call_with_client_options(requests_mock): + test_user_agent = "test-agent-v1.0" + cluster = create_valid_cluster(auth_type=AuthMethod.NO_AUTH, + client_options=ClientOptions(config={"user_agent_extra": test_user_agent})) + assert isinstance(cluster, Cluster) + + requests_mock.get(f"{cluster.endpoint}/test_api", json={'test': True}) + response = cluster.call_api("/test_api") + assert response.headers == {} + assert response.status_code == 200 + assert response.json() == {'test': True} + + assert test_user_agent in requests_mock.last_request.headers['User-Agent'] + + def test_connection_check_with_exception(mocker): cluster = create_valid_cluster() api_mock = mocker.patch.object(Cluster, 'call_api', side_effect=Exception('Attempt to connect to cluster failed')) diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_environment.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_environment.py index 769f59c2c..d3bb66691 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_environment.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_environment.py @@ -9,6 +9,9 @@ TEST_DATA_DIRECTORY = pathlib.Path(__file__).parent / "data" VALID_SERVICES_YAML = TEST_DATA_DIRECTORY / "services.yaml" +VALID_SERVICES_CLIENT_OPTIONS_YAML = TEST_DATA_DIRECTORY / "services_with_client_options.yaml" +# Value should match value in VALID_SERVICES_CLIENT_OPTIONS_YAML +USER_AGENT_EXTRA = "test-user-agent-v1.0" def create_file_in_tmp_path(tmp_path, file_name, content): @@ -30,6 +33,18 @@ def test_valid_services_yaml_to_environment_succeeds(): assert isinstance(env.backfill, Backfill) assert env.metrics_source is not None assert isinstance(env.metrics_source, MetricsSource) + assert env.client_options is None + + +def test_valid_services_yaml_with_client_options_are_propagated(): + env = Environment(VALID_SERVICES_CLIENT_OPTIONS_YAML) + stored_client_options_user_agent_extra = env.client_options.user_agent_extra + assert stored_client_options_user_agent_extra == USER_AGENT_EXTRA + assert env.source_cluster.client_options.user_agent_extra == stored_client_options_user_agent_extra + assert env.target_cluster.client_options.user_agent_extra == stored_client_options_user_agent_extra + assert env.replay.client_options.user_agent_extra == stored_client_options_user_agent_extra + assert env.backfill.client_options.user_agent_extra == stored_client_options_user_agent_extra + assert env.metrics_source.client_options.user_agent_extra == stored_client_options_user_agent_extra MINIMAL_YAML = """ @@ -46,6 +61,7 @@ def test_minimial_services_yaml_to_environment_works(tmp_path): assert env.source_cluster is None assert env.backfill is None assert env.metrics_source is None + assert env.client_options is None assert env.target_cluster is not None assert isinstance(env.target_cluster, Cluster) diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_metrics_source.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_metrics_source.py index d353f6e11..b10ddf4d3 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_metrics_source.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_metrics_source.py @@ -7,6 +7,7 @@ import requests_mock from botocore.stub import Stubber +from console_link.models.client_options import ClientOptions from console_link.models.factories import (UnsupportedMetricsSourceError, get_metrics_source) from console_link.models.metrics_source import (CloudwatchMetricsSource, @@ -17,6 +18,7 @@ TEST_DATA_DIRECTORY = pathlib.Path(__file__).parent / "data" AWS_REGION = "us-east-1" +USER_AGENT_EXTRA = "test-agent-v1.0" mock_metrics_list = {'captureProxy': ['kafkaCommitCount', 'captureConnectionDuration'], 'replayer': ['kafkaCommitCount']} @@ -31,11 +33,15 @@ def prometheus_ms(): # due to https://github.com/psf/requests/issues/6089, tests with request-mocker and query params will # fail if the endpoint doesn't start with http endpoint = "http://localhost:9090" - return PrometheusMetricsSource({ - "prometheus": { - "endpoint": endpoint - } - }) + return PrometheusMetricsSource( + config={ + "prometheus": { + "endpoint": endpoint + } + }, + client_options=ClientOptions(config={"user_agent_extra": USER_AGENT_EXTRA}) + + ) @pytest.fixture @@ -47,11 +53,14 @@ def cw_stubber(): @pytest.fixture def cw_ms(): - return CloudwatchMetricsSource({ - "cloudwatch": { - "aws_region": AWS_REGION - } - }) + return CloudwatchMetricsSource( + config={ + "cloudwatch": { + "aws_region": AWS_REGION + } + }, + client_options=ClientOptions(config={"user_agent_extra": USER_AGENT_EXTRA}) + ) def test_get_metrics_source(): diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_utils.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_utils.py new file mode 100644 index 000000000..2f38b322c --- /dev/null +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/test_utils.py @@ -0,0 +1,42 @@ +from console_link.models.client_options import ClientOptions +from console_link.models.utils import append_user_agent_header_for_requests, create_boto3_client +import requests.utils + + +USER_AGENT_EXTRA = "test-user-agent-v1.0" + + +def test_create_boto3_client_no_user_agent(): + client = create_boto3_client(aws_service_name="ecs") + user_agent_for_client = client.meta.config.user_agent + assert "Boto3" in user_agent_for_client + + +def test_create_boto3_client_with_user_agent(): + client_options = ClientOptions(config={"user_agent_extra": USER_AGENT_EXTRA}) + client = create_boto3_client(aws_service_name="ecs", client_options=client_options) + user_agent_for_client = client.meta.config.user_agent + assert "Boto3" in user_agent_for_client + assert USER_AGENT_EXTRA in user_agent_for_client + + +def test_append_user_agent_header_for_requests_no_headers(): + expected_headers = {"User-Agent": f"{requests.utils.default_user_agent()} {USER_AGENT_EXTRA}"} + result_headers = append_user_agent_header_for_requests(headers=None, user_agent_extra=USER_AGENT_EXTRA) + assert result_headers == expected_headers + + +def test_append_user_agent_header_for_requests_existing_headers(): + existing_headers = {"Accept": "/*", "Host": "macosx"} + expected_headers = dict(existing_headers) + expected_headers["User-Agent"] = f"{requests.utils.default_user_agent()} {USER_AGENT_EXTRA}" + result_headers = append_user_agent_header_for_requests(headers=existing_headers, user_agent_extra=USER_AGENT_EXTRA) + assert result_headers == expected_headers + + +def test_append_user_agent_header_for_requests_existing_headers_with_user_agent(): + existing_headers = {"Accept": "/*", "Host": "macosx", "User-Agent": "pyclient"} + expected_headers = dict(existing_headers) + expected_headers["User-Agent"] = f"pyclient {USER_AGENT_EXTRA}" + result_headers = append_user_agent_header_for_requests(headers=existing_headers, user_agent_extra=USER_AGENT_EXTRA) + assert result_headers == expected_headers diff --git a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/utils.py b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/utils.py index 7f6916105..75e35df10 100644 --- a/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/utils.py +++ b/TrafficCapture/dockerSolution/src/main/docker/migrationConsole/lib/console_link/tests/utils.py @@ -1,12 +1,14 @@ from typing import Dict, Optional from console_link.models.cluster import AuthMethod, Cluster +from console_link.models.client_options import ClientOptions def create_valid_cluster(endpoint: str = "https://opensearchtarget:9200", allow_insecure: bool = True, auth_type: AuthMethod = AuthMethod.BASIC_AUTH, - details: Optional[Dict] = None): + details: Optional[Dict] = None, + client_options: Optional[ClientOptions] = None): if details is None and auth_type == AuthMethod.BASIC_AUTH: details = {"username": "admin", "password": "myStrongPassword123!"} @@ -16,4 +18,4 @@ def create_valid_cluster(endpoint: str = "https://opensearchtarget:9200", "allow_insecure": allow_insecure, auth_type.name.lower(): details if details else {} } - return Cluster(custom_cluster_config) + return Cluster(config=custom_cluster_config, client_options=client_options) diff --git a/deployment/cdk/opensearch-service-migration/bin/createApp.ts b/deployment/cdk/opensearch-service-migration/bin/createApp.ts index 69440e010..96b27b564 100644 --- a/deployment/cdk/opensearch-service-migration/bin/createApp.ts +++ b/deployment/cdk/opensearch-service-migration/bin/createApp.ts @@ -11,15 +11,22 @@ export function createApp(): App { const account = process.env.CDK_DEFAULT_ACCOUNT; const region = process.env.CDK_DEFAULT_REGION; const migrationsAppRegistryARN = process.env.MIGRATIONS_APP_REGISTRY_ARN; - const customReplayerUserAgent = process.env.CUSTOM_REPLAYER_USER_AGENT; if (migrationsAppRegistryARN) { console.info(`App Registry mode is enabled for CFN stack tracking. Will attempt to import the App Registry application from the MIGRATIONS_APP_REGISTRY_ARN env variable of ${migrationsAppRegistryARN} and looking in the configured region of ${region}`); } + // Temporarily allow both means for providing an additional migrations User Agent, but remove CUSTOM_REPLAYER_USER_AGENT + // in future change + let migrationsUserAgent = undefined + if (process.env.CUSTOM_REPLAYER_USER_AGENT) + migrationsUserAgent = process.env.CUSTOM_REPLAYER_USER_AGENT + if (process.env.MIGRATIONS_USER_AGENT) + migrationsUserAgent = process.env.MIGRATIONS_USER_AGENT + new StackComposer(app, { migrationsAppRegistryARN: migrationsAppRegistryARN, - customReplayerUserAgent: customReplayerUserAgent, + migrationsUserAgent: migrationsUserAgent, migrationsSolutionVersion: version, env: { account: account, region: region } }); diff --git a/deployment/cdk/opensearch-service-migration/lib/migration-services-yaml.ts b/deployment/cdk/opensearch-service-migration/lib/migration-services-yaml.ts index bb492892f..24d5020c7 100644 --- a/deployment/cdk/opensearch-service-migration/lib/migration-services-yaml.ts +++ b/deployment/cdk/opensearch-service-migration/lib/migration-services-yaml.ts @@ -116,6 +116,10 @@ export class KafkaYaml { standard?: string | null; } +export class ClientOptions { + user_agent_extra?: string; +} + export class ServicesYaml { source_cluster?: ClusterYaml; target_cluster: ClusterYaml; @@ -125,6 +129,7 @@ export class ServicesYaml { metadata_migration?: MetadataMigrationYaml; replayer?: ECSReplayerYaml; kafka?: KafkaYaml; + client_options?: ClientOptions; stringify(): string { return yaml.stringify({ @@ -135,7 +140,8 @@ export class ServicesYaml { snapshot: this.snapshot?.toDict(), metadata_migration: this.metadata_migration, replay: this.replayer?.toDict(), - kafka: this.kafka + kafka: this.kafka, + client_options: this.client_options }, { 'nullStr': '' diff --git a/deployment/cdk/opensearch-service-migration/lib/stack-composer.ts b/deployment/cdk/opensearch-service-migration/lib/stack-composer.ts index 085257af3..f6f3ae88b 100644 --- a/deployment/cdk/opensearch-service-migration/lib/stack-composer.ts +++ b/deployment/cdk/opensearch-service-migration/lib/stack-composer.ts @@ -16,9 +16,16 @@ import {KafkaStack} from "./service-stacks/kafka-stack"; import {Application} from "@aws-cdk/aws-servicecatalogappregistry-alpha"; import {OpenSearchContainerStack} from "./service-stacks/opensearch-container-stack"; import {determineStreamingSourceType, StreamingSourceType} from "./streaming-source-type"; -import {MigrationSSMParameter, parseRemovalPolicy, validateFargateCpuArch, parseClusterDefinition, ClusterNoAuth, ClusterAuth} from "./common-utilities"; +import { + ClusterAuth, + ClusterNoAuth, + MigrationSSMParameter, + parseClusterDefinition, + parseRemovalPolicy, + validateFargateCpuArch +} from "./common-utilities"; import {ReindexFromSnapshotStack} from "./service-stacks/reindex-from-snapshot-stack"; -import {ClusterYaml, ServicesYaml} from "./migration-services-yaml"; +import {ClientOptions, ClusterYaml, ServicesYaml} from "./migration-services-yaml"; export interface StackPropsExt extends StackProps { readonly stage: string, @@ -27,9 +34,9 @@ export interface StackPropsExt extends StackProps { } export interface StackComposerProps extends StackProps { - readonly migrationsSolutionVersion: string + readonly migrationsSolutionVersion: string, readonly migrationsAppRegistryARN?: string, - readonly customReplayerUserAgent?: string + readonly migrationsUserAgent?: string } export class StackComposer { @@ -305,11 +312,11 @@ export class StackComposer { const domainRemovalPolicy = parseRemovalPolicy("domainRemovalPolicy", domainRemovalPolicyName) let trafficReplayerCustomUserAgent - if (props.customReplayerUserAgent && trafficReplayerUserAgentSuffix) { - trafficReplayerCustomUserAgent = `${props.customReplayerUserAgent};${trafficReplayerUserAgentSuffix}` + if (props.migrationsUserAgent && trafficReplayerUserAgentSuffix) { + trafficReplayerCustomUserAgent = `${props.migrationsUserAgent};${trafficReplayerUserAgentSuffix}` } else { - trafficReplayerCustomUserAgent = trafficReplayerUserAgentSuffix ?? props.customReplayerUserAgent + trafficReplayerCustomUserAgent = trafficReplayerUserAgentSuffix ?? props.migrationsUserAgent } if (sourceClusterDisabled && (sourceCluster || captureProxyESServiceEnabled || elasticsearchServiceEnabled || captureProxyServiceEnabled)) { @@ -346,6 +353,11 @@ export class StackComposer { } let servicesYaml = new ServicesYaml(); + if (props.migrationsUserAgent) { + servicesYaml.client_options = new ClientOptions() + servicesYaml.client_options.user_agent_extra = props.migrationsUserAgent + } + // There is an assumption here that for any deployment we will always have a target cluster, whether that be a // created Domain like below or an imported one let openSearchStack diff --git a/deployment/cdk/opensearch-service-migration/test/createApp.test.ts b/deployment/cdk/opensearch-service-migration/test/createApp.test.ts index 9d8906f71..e0271643c 100644 --- a/deployment/cdk/opensearch-service-migration/test/createApp.test.ts +++ b/deployment/cdk/opensearch-service-migration/test/createApp.test.ts @@ -39,7 +39,7 @@ describe('createApp', () => { process.env.CDK_DEFAULT_ACCOUNT = 'test-account'; process.env.CDK_DEFAULT_REGION = 'test-region'; process.env.MIGRATIONS_APP_REGISTRY_ARN = 'test-arn'; - process.env.CUSTOM_REPLAYER_USER_AGENT = 'test-user-agent'; + process.env.MIGRATIONS_USER_AGENT = 'test-user-agent'; const consoleSpy = jest.spyOn(console, 'info').mockImplementation(); const mockAddTag = jest.fn(); @@ -58,7 +58,7 @@ describe('createApp', () => { expect.any(Object), { migrationsAppRegistryARN: 'test-arn', - customReplayerUserAgent: 'test-user-agent', + migrationsUserAgent: 'test-user-agent', migrationsSolutionVersion: '1.0.0', env: { account: 'test-account', region: 'test-region' }, } @@ -74,4 +74,4 @@ describe('createApp', () => { consoleSpy.mockRestore(); }); -}); \ No newline at end of file +}); diff --git a/deployment/cdk/opensearch-service-migration/test/migration-services-yaml.test.ts b/deployment/cdk/opensearch-service-migration/test/migration-services-yaml.test.ts index e1284a185..784ada0cd 100644 --- a/deployment/cdk/opensearch-service-migration/test/migration-services-yaml.test.ts +++ b/deployment/cdk/opensearch-service-migration/test/migration-services-yaml.test.ts @@ -1,226 +1,282 @@ -import { ContainerImage } from "aws-cdk-lib/aws-ecs"; -import { ClusterAuth, ClusterBasicAuth, ClusterNoAuth } from "../lib/common-utilities" -import { ClusterYaml, RFSBackfillYaml, ServicesYaml, SnapshotYaml } from "../lib/migration-services-yaml" -import { Template, Capture, Match } from "aws-cdk-lib/assertions"; -import { MigrationConsoleStack } from "../lib/service-stacks/migration-console-stack"; -import { createStackComposer } from "./test-utils"; +import {ContainerImage} from "aws-cdk-lib/aws-ecs"; +import {ClusterAuth, ClusterBasicAuth, ClusterNoAuth} from "../lib/common-utilities" +import {ClusterYaml, RFSBackfillYaml, ServicesYaml, SnapshotYaml} from "../lib/migration-services-yaml" +import {Template, Capture, Match} from "aws-cdk-lib/assertions"; +import {MigrationConsoleStack} from "../lib/service-stacks/migration-console-stack"; +import {createStackComposer} from "./test-utils"; import * as yaml from 'yaml'; import {describe, afterEach, beforeEach, test, expect, jest} from '@jest/globals'; jest.mock('aws-cdk-lib/aws-ecr-assets'); describe('Migration Services YAML Tests', () => { - beforeEach(() => { - jest.spyOn(ContainerImage, 'fromDockerImageAsset').mockImplementation(() => ContainerImage.fromRegistry("ServiceImage")); -}); + beforeEach(() => { + jest.spyOn(ContainerImage, 'fromDockerImageAsset').mockImplementation(() => ContainerImage.fromRegistry("ServiceImage")); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + jest.restoreAllMocks(); + jest.resetAllMocks(); + }); + + test('Test default servicesYaml can be stringified', () => { + const servicesYaml = new ServicesYaml(); + expect(servicesYaml.metrics_source).toBeDefined(); + expect(Object.keys(servicesYaml.metrics_source)).toContain("cloudwatch"); + const yaml = servicesYaml.stringify(); + expect(yaml).toBe("metrics_source:\n cloudwatch:\n"); + }); + + test('Test ClusterAuth.toDict', () => { + const clusterAuth = new ClusterAuth({noAuth: new ClusterNoAuth()}); + const dict = clusterAuth.toDict(); + expect(dict).toEqual({no_auth: ""}); + + const basicAuth = new ClusterAuth({basicAuth: new ClusterBasicAuth({username: "XXX", password: "123"})}); + const basicAuthDict = basicAuth.toDict(); + expect(basicAuthDict).toEqual({basic_auth: {username: "XXX", password: "123"}}); + }) + + test('Test servicesYaml with target cluster can be stringified', () => { + let servicesYaml = new ServicesYaml(); - afterEach(() => { - jest.clearAllMocks(); - jest.resetModules(); - jest.restoreAllMocks(); - jest.resetAllMocks(); - }); - - test('Test default servicesYaml can be stringified', () => { - const servicesYaml = new ServicesYaml(); - expect(servicesYaml.metrics_source).toBeDefined(); - expect(Object.keys(servicesYaml.metrics_source)).toContain("cloudwatch"); - const yaml = servicesYaml.stringify(); - expect(yaml).toBe("metrics_source:\n cloudwatch:\n"); - }); - - test('Test ClusterAuth.toDict', () => { - const clusterAuth = new ClusterAuth({noAuth: new ClusterNoAuth()}); - const dict = clusterAuth.toDict(); - expect(dict).toEqual({no_auth: ""}); - - const basicAuth = new ClusterAuth({basicAuth: new ClusterBasicAuth({username: "XXX", password: "123"})}); - const basicAuthDict = basicAuth.toDict(); - expect(basicAuthDict).toEqual({basic_auth: {username: "XXX", password: "123"}}); - }) - - test('Test servicesYaml with target cluster can be stringified', () => { - let servicesYaml = new ServicesYaml(); - - const cluster = new ClusterYaml({ - 'endpoint': 'https://abc.com', - auth: new ClusterAuth({noAuth: new ClusterNoAuth()}) + const cluster = new ClusterYaml({ + 'endpoint': 'https://abc.com', + auth: new ClusterAuth({noAuth: new ClusterNoAuth()}) + }); + servicesYaml.target_cluster = cluster; + + expect(servicesYaml.target_cluster).toBeDefined(); + const yaml = servicesYaml.stringify(); + expect(yaml).toBe(`target_cluster:\n endpoint: ${cluster.endpoint}\n no_auth: ""\nmetrics_source:\n cloudwatch:\n`); }); - servicesYaml.target_cluster = cluster; - - expect(servicesYaml.target_cluster).toBeDefined(); - const yaml = servicesYaml.stringify(); - expect(yaml).toBe(`target_cluster:\n endpoint: ${cluster.endpoint}\n no_auth: ""\nmetrics_source:\n cloudwatch:\n`); - }); - - test('Test servicesYaml with source and target cluster can be stringified', () => { - let servicesYaml = new ServicesYaml(); - const targetCluster = new ClusterYaml({ - 'endpoint': 'https://abc.com', - auth: new ClusterAuth({noAuth: new ClusterNoAuth()}) + + test('Test servicesYaml with source and target cluster can be stringified', () => { + let servicesYaml = new ServicesYaml(); + const targetCluster = new ClusterYaml({ + 'endpoint': 'https://abc.com', + auth: new ClusterAuth({noAuth: new ClusterNoAuth()}) + }); + servicesYaml.target_cluster = targetCluster; + const sourceClusterUser = "abc"; + const sourceClusterPassword = "XXXXX"; + const basicAuth = new ClusterBasicAuth({username: sourceClusterUser, password: sourceClusterPassword}); + const sourceCluster = new ClusterYaml({ + 'endpoint': 'https://xyz.com:9200', + 'auth': new ClusterAuth({basicAuth: basicAuth}), + }); + servicesYaml.source_cluster = sourceCluster; + + expect(servicesYaml.target_cluster).toBeDefined(); + expect(servicesYaml.source_cluster).toBeDefined(); + const yaml = servicesYaml.stringify(); + const sourceClusterYaml = `source_cluster:\n endpoint: ${sourceCluster.endpoint}\n basic_auth:\n username: ${sourceClusterUser}\n password: ${sourceClusterPassword}\n` + expect(yaml).toBe(`${sourceClusterYaml}target_cluster:\n endpoint: ${targetCluster.endpoint}\n no_auth: ""\nmetrics_source:\n cloudwatch:\n`); }); - servicesYaml.target_cluster = targetCluster; - const sourceClusterUser = "abc"; - const sourceClusterPassword = "XXXXX"; - const basicAuth = new ClusterBasicAuth({ username: sourceClusterUser, password: sourceClusterPassword }); - const sourceCluster = new ClusterYaml({ 'endpoint': 'https://xyz.com:9200', - 'auth': new ClusterAuth({basicAuth: basicAuth}), + + test('Test servicesYaml with rfs backfill can be stringified', () => { + const clusterName = "migration-cluster-name"; + const serviceName = "rfs-service-name"; + const region = "us-east-1" + let servicesYaml = new ServicesYaml(); + let rfsBackfillYaml = new RFSBackfillYaml(); + rfsBackfillYaml.ecs.cluster_name = clusterName; + rfsBackfillYaml.ecs.service_name = serviceName; + rfsBackfillYaml.ecs.aws_region = region; + servicesYaml.backfill = rfsBackfillYaml; + + expect(servicesYaml.backfill).toBeDefined(); + expect(servicesYaml.backfill).toBeDefined(); + expect(servicesYaml.backfill instanceof RFSBackfillYaml).toBeTruthy(); + const yaml = servicesYaml.stringify(); + expect(yaml).toBe(`metrics_source:\n cloudwatch:\nbackfill:\n reindex_from_snapshot:\n ecs:\n cluster_name: ${clusterName}\n service_name: ${serviceName}\n aws_region: ${region}\n`); }); - servicesYaml.source_cluster = sourceCluster; - - expect(servicesYaml.target_cluster).toBeDefined(); - expect(servicesYaml.source_cluster).toBeDefined(); - const yaml = servicesYaml.stringify(); - const sourceClusterYaml = `source_cluster:\n endpoint: ${sourceCluster.endpoint}\n basic_auth:\n username: ${sourceClusterUser}\n password: ${sourceClusterPassword}\n` - expect(yaml).toBe(`${sourceClusterYaml}target_cluster:\n endpoint: ${targetCluster.endpoint}\n no_auth: ""\nmetrics_source:\n cloudwatch:\n`); - }); - - test('Test servicesYaml with rfs backfill can be stringified', () => { - const clusterName = "migration-cluster-name"; - const serviceName = "rfs-service-name"; - const region = "us-east-1" - let servicesYaml = new ServicesYaml(); - let rfsBackfillYaml = new RFSBackfillYaml(); - rfsBackfillYaml.ecs.cluster_name = clusterName; - rfsBackfillYaml.ecs.service_name = serviceName; - rfsBackfillYaml.ecs.aws_region = region; - servicesYaml.backfill = rfsBackfillYaml; - - expect(servicesYaml.backfill).toBeDefined(); - expect(servicesYaml.backfill).toBeDefined(); - expect(servicesYaml.backfill instanceof RFSBackfillYaml).toBeTruthy(); - const yaml = servicesYaml.stringify(); - expect(yaml).toBe(`metrics_source:\n cloudwatch:\nbackfill:\n reindex_from_snapshot:\n ecs:\n cluster_name: ${clusterName}\n service_name: ${serviceName}\n aws_region: ${region}\n`); - }); - - test('Test servicesYaml without backfill does not include backend section', () => { - let servicesYaml = new ServicesYaml(); - const yaml = servicesYaml.stringify(); - expect(yaml).toBe(`metrics_source:\n cloudwatch:\n`); - }); - - test('Test SnapshotYaml for filesystem only includes fs', () => { - let fsSnapshot = new SnapshotYaml(); - fsSnapshot.fs = {"repo_path": "/path/to/shared/volume"} - const fsSnapshotDict = fsSnapshot.toDict() - expect(fsSnapshotDict).toBeDefined(); - expect(fsSnapshotDict).toHaveProperty("fs"); - expect(fsSnapshotDict["fs"]).toHaveProperty("repo_path"); - expect(fsSnapshotDict).not.toHaveProperty("s3"); - }); - - test('Test SnapshotYaml for s3 only includes s3', () => { - let s3Snapshot = new SnapshotYaml(); - s3Snapshot.s3 = {"repo_uri": "s3://repo/path", "aws_region": "us-east-1"} - const s3SnapshotDict = s3Snapshot.toDict() - expect(s3SnapshotDict).toBeDefined(); - expect(s3SnapshotDict).toHaveProperty("s3"); - expect(s3SnapshotDict["s3"]).toHaveProperty("repo_uri"); - expect(s3SnapshotDict).not.toHaveProperty("fs"); - }); - -test('Test that services yaml parameter is created by migration console stack with target domain creation', () => { - const contextOptions = { - vpcEnabled: true, - migrationAssistanceEnabled: true, - migrationConsoleServiceEnabled: true, - sourceCluster: { - "endpoint": "https://test-cluster", - "auth": {"type": "none"} - }, - reindexFromSnapshotServiceEnabled: true, - trafficReplayerServiceEnabled: true, - fineGrainedManagerUserName: "admin", - fineGrainedManagerUserSecretManagerKeyARN: "arn:aws:secretsmanager:us-east-1:12345678912:secret:master-user-os-pass-123abc", - nodeToNodeEncryptionEnabled: true, // required if FGAC is being used - encryptionAtRestEnabled: true, // required if FGAC is being used - enforceHTTPS: true // required if FGAC is being used - } - - const stacks = createStackComposer(contextOptions) - - const migrationConsoleStack: MigrationConsoleStack = (stacks.stacks.filter((s) => s instanceof MigrationConsoleStack)[0]) as MigrationConsoleStack - const migrationConsoleStackTemplate = Template.fromStack(migrationConsoleStack) - - const valueCapture = new Capture(); - migrationConsoleStackTemplate.hasResourceProperties("AWS::SSM::Parameter", { - Type: "String", - Name: Match.stringLikeRegexp("/migration/.*/.*/servicesYamlFile"), - Value: valueCapture, + + test('Test servicesYaml without backfill does not include backend section', () => { + let servicesYaml = new ServicesYaml(); + const yaml = servicesYaml.stringify(); + expect(yaml).toBe(`metrics_source:\n cloudwatch:\n`); + }); + + test('Test SnapshotYaml for filesystem only includes fs', () => { + let fsSnapshot = new SnapshotYaml(); + fsSnapshot.fs = {"repo_path": "/path/to/shared/volume"} + const fsSnapshotDict = fsSnapshot.toDict() + expect(fsSnapshotDict).toBeDefined(); + expect(fsSnapshotDict).toHaveProperty("fs"); + expect(fsSnapshotDict["fs"]).toHaveProperty("repo_path"); + expect(fsSnapshotDict).not.toHaveProperty("s3"); }); - const value = valueCapture.asObject() - expect(value).toBeDefined(); - expect(value['Fn::Join']).toBeInstanceOf(Array); - expect(value['Fn::Join'][1]).toBeInstanceOf(Array) - // join the strings together to get the yaml file contents - const yamlFileContents = value['Fn::Join'][1].join('') - expect(yamlFileContents).toContain('source_cluster') - expect(yamlFileContents).toContain('target_cluster') - - expect(yamlFileContents).toContain('basic_auth') - expect(yamlFileContents).toContain(`username: ${contextOptions.fineGrainedManagerUserName}`) - expect(yamlFileContents).toContain(`password_from_secret_arn: ${contextOptions.fineGrainedManagerUserSecretManagerKeyARN}`) - expect(yamlFileContents).toContain('metrics_source:\n cloudwatch:') - expect(yamlFileContents).toContain('kafka') - // Validates that the file can be parsed as valid yaml and has the expected fields - const parsedFromYaml = yaml.parse(yamlFileContents); - // Validates that the file has the expected fields - const expectedFields = ['source_cluster', 'target_cluster', 'metrics_source', 'backfill', 'snapshot', 'metadata_migration', 'replay', 'kafka']; - expect(Object.keys(parsedFromYaml).length).toEqual(expectedFields.length) - expect(new Set(Object.keys(parsedFromYaml))).toEqual(new Set(expectedFields)) - }); - - - test('Test that services yaml parameter is created by migration console stack with provided target domain', () => { - const contextOptions = { - vpcEnabled: true, - migrationAssistanceEnabled: true, - migrationConsoleServiceEnabled: true, - sourceCluster: { - "endpoint": "https://test-cluster", - "auth": {"type": "none"} - }, - targetCluster: { - "endpoint": "https://target-cluster", - "auth": { - "type": "basic", - "username": "admin", - "passwordFromSecretArn": "arn:aws:secretsmanager:us-east-1:12345678912:secret:master-user-os-pass-123abc" - } - }, - reindexFromSnapshotServiceEnabled: true, - trafficReplayerServiceEnabled: true, - } - - const stacks = createStackComposer(contextOptions) - - const migrationConsoleStack: MigrationConsoleStack = (stacks.stacks.filter((s) => s instanceof MigrationConsoleStack)[0]) as MigrationConsoleStack - const migrationConsoleStackTemplate = Template.fromStack(migrationConsoleStack) - - const valueCapture = new Capture(); - migrationConsoleStackTemplate.hasResourceProperties("AWS::SSM::Parameter", { - Type: "String", - Name: Match.stringLikeRegexp("/migration/.*/.*/servicesYamlFile"), - Value: valueCapture, + + test('Test SnapshotYaml for s3 only includes s3', () => { + let s3Snapshot = new SnapshotYaml(); + s3Snapshot.s3 = {"repo_uri": "s3://repo/path", "aws_region": "us-east-1"} + const s3SnapshotDict = s3Snapshot.toDict() + expect(s3SnapshotDict).toBeDefined(); + expect(s3SnapshotDict).toHaveProperty("s3"); + expect(s3SnapshotDict["s3"]).toHaveProperty("repo_uri"); + expect(s3SnapshotDict).not.toHaveProperty("fs"); + }); + + test('Test that services yaml parameter is created by migration console stack with target domain creation', () => { + const contextOptions = { + vpcEnabled: true, + migrationAssistanceEnabled: true, + migrationConsoleServiceEnabled: true, + sourceCluster: { + "endpoint": "https://test-cluster", + "auth": {"type": "none"} + }, + reindexFromSnapshotServiceEnabled: true, + trafficReplayerServiceEnabled: true, + fineGrainedManagerUserName: "admin", + fineGrainedManagerUserSecretManagerKeyARN: "arn:aws:secretsmanager:us-east-1:12345678912:secret:master-user-os-pass-123abc", + nodeToNodeEncryptionEnabled: true, // required if FGAC is being used + encryptionAtRestEnabled: true, // required if FGAC is being used + enforceHTTPS: true // required if FGAC is being used + } + + const stacks = createStackComposer(contextOptions) + + const migrationConsoleStack: MigrationConsoleStack = (stacks.stacks.filter((s) => s instanceof MigrationConsoleStack)[0]) as MigrationConsoleStack + const migrationConsoleStackTemplate = Template.fromStack(migrationConsoleStack) + + const valueCapture = new Capture(); + migrationConsoleStackTemplate.hasResourceProperties("AWS::SSM::Parameter", { + Type: "String", + Name: Match.stringLikeRegexp("/migration/.*/.*/servicesYamlFile"), + Value: valueCapture, + }); + const value = valueCapture.asObject() + expect(value).toBeDefined(); + expect(value['Fn::Join']).toBeInstanceOf(Array); + expect(value['Fn::Join'][1]).toBeInstanceOf(Array) + // join the strings together to get the yaml file contents + const yamlFileContents = value['Fn::Join'][1].join('') + expect(yamlFileContents).toContain('source_cluster') + expect(yamlFileContents).toContain('target_cluster') + + expect(yamlFileContents).toContain('basic_auth') + expect(yamlFileContents).toContain(`username: ${contextOptions.fineGrainedManagerUserName}`) + expect(yamlFileContents).toContain(`password_from_secret_arn: ${contextOptions.fineGrainedManagerUserSecretManagerKeyARN}`) + expect(yamlFileContents).toContain('metrics_source:\n cloudwatch:') + expect(yamlFileContents).toContain('kafka') + // Validates that the file can be parsed as valid yaml and has the expected fields + const parsedFromYaml = yaml.parse(yamlFileContents); + // Validates that the file has the expected fields + const expectedFields = ['source_cluster', 'target_cluster', 'metrics_source', 'backfill', 'snapshot', 'metadata_migration', 'replay', 'kafka']; + expect(Object.keys(parsedFromYaml).length).toEqual(expectedFields.length) + expect(new Set(Object.keys(parsedFromYaml))).toEqual(new Set(expectedFields)) + }); + + + test('Test that services yaml parameter is created by migration console stack with provided target domain', () => { + const contextOptions = { + vpcEnabled: true, + migrationAssistanceEnabled: true, + migrationConsoleServiceEnabled: true, + sourceCluster: { + "endpoint": "https://test-cluster", + "auth": {"type": "none"} + }, + targetCluster: { + "endpoint": "https://target-cluster", + "auth": { + "type": "basic", + "username": "admin", + "passwordFromSecretArn": "arn:aws:secretsmanager:us-east-1:12345678912:secret:master-user-os-pass-123abc" + } + }, + reindexFromSnapshotServiceEnabled: true, + trafficReplayerServiceEnabled: true, + } + + const stacks = createStackComposer(contextOptions) + + const migrationConsoleStack: MigrationConsoleStack = (stacks.stacks.filter((s) => s instanceof MigrationConsoleStack)[0]) as MigrationConsoleStack + const migrationConsoleStackTemplate = Template.fromStack(migrationConsoleStack) + + const valueCapture = new Capture(); + migrationConsoleStackTemplate.hasResourceProperties("AWS::SSM::Parameter", { + Type: "String", + Name: Match.stringLikeRegexp("/migration/.*/.*/servicesYamlFile"), + Value: valueCapture, + }); + const value = valueCapture.asObject() + expect(value).toBeDefined(); + expect(value['Fn::Join']).toBeInstanceOf(Array); + expect(value['Fn::Join'][1]).toBeInstanceOf(Array) + // join the strings together to get the yaml file contents + const yamlFileContents = value['Fn::Join'][1].join('') + expect(yamlFileContents).toContain('source_cluster') + expect(yamlFileContents).toContain('target_cluster') + + expect(yamlFileContents).toContain('basic_auth') + expect(yamlFileContents).toContain(`username: ${contextOptions.targetCluster.auth.username}`) + expect(yamlFileContents).toContain(`password_from_secret_arn: ${contextOptions.targetCluster.auth.passwordFromSecretArn}`) + expect(yamlFileContents).toContain('metrics_source:\n cloudwatch:') + expect(yamlFileContents).toContain('kafka') + // Validates that the file can be parsed as valid yaml and has the expected fields + const parsedFromYaml = yaml.parse(yamlFileContents); + // Validates that the file has the expected fields + const expectedFields = ['source_cluster', 'target_cluster', 'metrics_source', 'backfill', 'snapshot', 'metadata_migration', 'replay', 'kafka']; + expect(Object.keys(parsedFromYaml).length).toEqual(expectedFields.length) + expect(new Set(Object.keys(parsedFromYaml))).toEqual(new Set(expectedFields)) + }); + test('Test that services yaml parameter contains client_options when set', () => { + const contextOptions = { + vpcEnabled: true, + migrationAssistanceEnabled: true, + migrationConsoleServiceEnabled: true, + sourceCluster: { + "endpoint": "https://test-cluster", + "auth": {"type": "none"} + }, + targetCluster: { + "endpoint": "https://target-cluster", + "auth": { + "type": "basic", + "username": "admin", + "passwordFromSecretArn": "arn:aws:secretsmanager:us-east-1:12345678912:secret:master-user-os-pass-123abc" + } + }, + reindexFromSnapshotServiceEnabled: true, + trafficReplayerServiceEnabled: true, + } + const userAgent = "test-agent-v1.0" + const stacks = createStackComposer(contextOptions, userAgent) + + const migrationConsoleStack: MigrationConsoleStack = (stacks.stacks.filter((s) => s instanceof MigrationConsoleStack)[0]) as MigrationConsoleStack + const migrationConsoleStackTemplate = Template.fromStack(migrationConsoleStack) + + const valueCapture = new Capture(); + migrationConsoleStackTemplate.hasResourceProperties("AWS::SSM::Parameter", { + Type: "String", + Name: Match.stringLikeRegexp("/migration/.*/.*/servicesYamlFile"), + Value: valueCapture, + }); + console.error(valueCapture) + const value = valueCapture.asObject() + expect(value).toBeDefined(); + expect(value['Fn::Join']).toBeInstanceOf(Array); + expect(value['Fn::Join'][1]).toBeInstanceOf(Array) + // join the strings together to get the yaml file contents + const yamlFileContents = value['Fn::Join'][1].join('') + expect(yamlFileContents).toContain('source_cluster') + expect(yamlFileContents).toContain('target_cluster') + + expect(yamlFileContents).toContain('basic_auth') + expect(yamlFileContents).toContain(`username: ${contextOptions.targetCluster.auth.username}`) + expect(yamlFileContents).toContain(`password_from_secret_arn: ${contextOptions.targetCluster.auth.passwordFromSecretArn}`) + expect(yamlFileContents).toContain('metrics_source:\n cloudwatch:') + expect(yamlFileContents).toContain('kafka') + expect(yamlFileContents).toContain(`user_agent_extra: ${userAgent}`) + // Validates that the file can be parsed as valid yaml and has the expected fields + const parsedFromYaml = yaml.parse(yamlFileContents); + // Validates that the file has the expected fields + const expectedFields = ['source_cluster', 'target_cluster', 'metrics_source', 'backfill', 'snapshot', 'metadata_migration', 'replay', 'kafka', 'client_options']; + expect(Object.keys(parsedFromYaml).length).toEqual(expectedFields.length) + expect(new Set(Object.keys(parsedFromYaml))).toEqual(new Set(expectedFields)) }); - const value = valueCapture.asObject() - expect(value).toBeDefined(); - expect(value['Fn::Join']).toBeInstanceOf(Array); - expect(value['Fn::Join'][1]).toBeInstanceOf(Array) - // join the strings together to get the yaml file contents - const yamlFileContents = value['Fn::Join'][1].join('') - expect(yamlFileContents).toContain('source_cluster') - expect(yamlFileContents).toContain('target_cluster') - - expect(yamlFileContents).toContain('basic_auth') - expect(yamlFileContents).toContain(`username: ${contextOptions.targetCluster.auth.username}`) - expect(yamlFileContents).toContain(`password_from_secret_arn: ${contextOptions.targetCluster.auth.passwordFromSecretArn}`) - expect(yamlFileContents).toContain('metrics_source:\n cloudwatch:') - expect(yamlFileContents).toContain('kafka') - // Validates that the file can be parsed as valid yaml and has the expected fields - const parsedFromYaml = yaml.parse(yamlFileContents); - // Validates that the file has the expected fields - const expectedFields = ['source_cluster', 'target_cluster', 'metrics_source', 'backfill', 'snapshot', 'metadata_migration', 'replay', 'kafka']; - expect(Object.keys(parsedFromYaml).length).toEqual(expectedFields.length) - expect(new Set(Object.keys(parsedFromYaml))).toEqual(new Set(expectedFields)) - }); }); diff --git a/deployment/cdk/opensearch-service-migration/test/test-utils.ts b/deployment/cdk/opensearch-service-migration/test/test-utils.ts index 7784ef6ce..4f6a832bf 100644 --- a/deployment/cdk/opensearch-service-migration/test/test-utils.ts +++ b/deployment/cdk/opensearch-service-migration/test/test-utils.ts @@ -1,7 +1,7 @@ import {StackComposer} from "../lib/stack-composer"; import {App} from "aws-cdk-lib"; -export function createStackComposer(contextBlock: { [x: string]: (any); }) { +export function createStackComposer(contextBlock: { [x: string]: (any); }, migrationsUserAgent?: string) { contextBlock.stage = "unit-test" const app = new App({ context: { @@ -11,7 +11,8 @@ export function createStackComposer(contextBlock: { [x: string]: (any); }) { }) return new StackComposer(app, { env: {account: "test-account", region: "us-east-1"}, - migrationsSolutionVersion: "1.0.0" + migrationsSolutionVersion: "1.0.0", + migrationsUserAgent: migrationsUserAgent }) }