dbt-platform-helper 13.1.0__py3-none-any.whl → 15.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. dbt_platform_helper/COMMANDS.md +107 -27
  2. dbt_platform_helper/commands/application.py +5 -6
  3. dbt_platform_helper/commands/codebase.py +31 -10
  4. dbt_platform_helper/commands/conduit.py +3 -5
  5. dbt_platform_helper/commands/config.py +20 -311
  6. dbt_platform_helper/commands/copilot.py +18 -391
  7. dbt_platform_helper/commands/database.py +17 -9
  8. dbt_platform_helper/commands/environment.py +20 -14
  9. dbt_platform_helper/commands/generate.py +0 -3
  10. dbt_platform_helper/commands/internal.py +140 -0
  11. dbt_platform_helper/commands/notify.py +58 -78
  12. dbt_platform_helper/commands/pipeline.py +23 -19
  13. dbt_platform_helper/commands/secrets.py +39 -93
  14. dbt_platform_helper/commands/version.py +7 -12
  15. dbt_platform_helper/constants.py +52 -7
  16. dbt_platform_helper/domain/codebase.py +89 -39
  17. dbt_platform_helper/domain/conduit.py +335 -76
  18. dbt_platform_helper/domain/config.py +381 -0
  19. dbt_platform_helper/domain/copilot.py +398 -0
  20. dbt_platform_helper/domain/copilot_environment.py +8 -8
  21. dbt_platform_helper/domain/database_copy.py +2 -2
  22. dbt_platform_helper/domain/maintenance_page.py +254 -430
  23. dbt_platform_helper/domain/notify.py +64 -0
  24. dbt_platform_helper/domain/pipelines.py +43 -35
  25. dbt_platform_helper/domain/plans.py +41 -0
  26. dbt_platform_helper/domain/secrets.py +279 -0
  27. dbt_platform_helper/domain/service.py +570 -0
  28. dbt_platform_helper/domain/terraform_environment.py +14 -13
  29. dbt_platform_helper/domain/update_alb_rules.py +412 -0
  30. dbt_platform_helper/domain/versioning.py +249 -0
  31. dbt_platform_helper/{providers → entities}/platform_config_schema.py +75 -82
  32. dbt_platform_helper/entities/semantic_version.py +83 -0
  33. dbt_platform_helper/entities/service.py +339 -0
  34. dbt_platform_helper/platform_exception.py +4 -0
  35. dbt_platform_helper/providers/autoscaling.py +24 -0
  36. dbt_platform_helper/providers/aws/__init__.py +0 -0
  37. dbt_platform_helper/providers/aws/exceptions.py +70 -0
  38. dbt_platform_helper/providers/aws/interfaces.py +13 -0
  39. dbt_platform_helper/providers/aws/opensearch.py +23 -0
  40. dbt_platform_helper/providers/aws/redis.py +21 -0
  41. dbt_platform_helper/providers/aws/sso_auth.py +75 -0
  42. dbt_platform_helper/providers/cache.py +40 -4
  43. dbt_platform_helper/providers/cloudformation.py +1 -1
  44. dbt_platform_helper/providers/config.py +137 -19
  45. dbt_platform_helper/providers/config_validator.py +112 -51
  46. dbt_platform_helper/providers/copilot.py +24 -16
  47. dbt_platform_helper/providers/ecr.py +89 -7
  48. dbt_platform_helper/providers/ecs.py +228 -36
  49. dbt_platform_helper/providers/environment_variable.py +24 -0
  50. dbt_platform_helper/providers/files.py +1 -1
  51. dbt_platform_helper/providers/io.py +36 -4
  52. dbt_platform_helper/providers/kms.py +22 -0
  53. dbt_platform_helper/providers/load_balancers.py +402 -42
  54. dbt_platform_helper/providers/logs.py +72 -0
  55. dbt_platform_helper/providers/parameter_store.py +134 -0
  56. dbt_platform_helper/providers/s3.py +21 -0
  57. dbt_platform_helper/providers/schema_migrations/__init__.py +0 -0
  58. dbt_platform_helper/providers/schema_migrations/schema_v0_to_v1_migration.py +43 -0
  59. dbt_platform_helper/providers/schema_migrator.py +77 -0
  60. dbt_platform_helper/providers/secrets.py +5 -5
  61. dbt_platform_helper/providers/slack_channel_notifier.py +62 -0
  62. dbt_platform_helper/providers/terraform_manifest.py +121 -19
  63. dbt_platform_helper/providers/version.py +106 -23
  64. dbt_platform_helper/providers/version_status.py +27 -0
  65. dbt_platform_helper/providers/vpc.py +36 -5
  66. dbt_platform_helper/providers/yaml_file.py +58 -2
  67. dbt_platform_helper/templates/environment-pipelines/main.tf +4 -3
  68. dbt_platform_helper/templates/svc/overrides/cfn.patches.yml +5 -0
  69. dbt_platform_helper/utilities/decorators.py +103 -0
  70. dbt_platform_helper/utils/application.py +119 -22
  71. dbt_platform_helper/utils/aws.py +39 -150
  72. dbt_platform_helper/utils/deep_merge.py +10 -0
  73. dbt_platform_helper/utils/git.py +1 -14
  74. dbt_platform_helper/utils/validation.py +1 -1
  75. {dbt_platform_helper-13.1.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/METADATA +11 -20
  76. dbt_platform_helper-15.16.0.dist-info/RECORD +118 -0
  77. {dbt_platform_helper-13.1.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/WHEEL +1 -1
  78. platform_helper.py +3 -1
  79. terraform/elasticache-redis/plans.yml +85 -0
  80. terraform/opensearch/plans.yml +71 -0
  81. terraform/postgres/plans.yml +128 -0
  82. dbt_platform_helper/addon-plans.yml +0 -224
  83. dbt_platform_helper/providers/aws.py +0 -37
  84. dbt_platform_helper/providers/opensearch.py +0 -36
  85. dbt_platform_helper/providers/redis.py +0 -34
  86. dbt_platform_helper/providers/semantic_version.py +0 -126
  87. dbt_platform_helper/templates/svc/manifest-backend.yml +0 -69
  88. dbt_platform_helper/templates/svc/manifest-public.yml +0 -109
  89. dbt_platform_helper/utils/cloudfoundry.py +0 -14
  90. dbt_platform_helper/utils/files.py +0 -53
  91. dbt_platform_helper/utils/manifests.py +0 -18
  92. dbt_platform_helper/utils/versioning.py +0 -238
  93. dbt_platform_helper-13.1.0.dist-info/RECORD +0 -96
  94. {dbt_platform_helper-13.1.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/entry_points.txt +0 -0
  95. {dbt_platform_helper-13.1.0.dist-info → dbt_platform_helper-15.16.0.dist-info/licenses}/LICENSE +0 -0
@@ -77,12 +77,43 @@ class VpcProvider:
77
77
  return vpc_id
78
78
 
79
79
  def _get_security_groups(self, app: str, env: str, vpc_id: str) -> list:
80
-
81
80
  vpc_filter = {"Name": "vpc-id", "Values": [vpc_id]}
82
- tag_filter = {"Name": f"tag:Name", "Values": [f"copilot-{app}-{env}-env"]}
83
- response = self.ec2_client.describe_security_groups(Filters=[vpc_filter, tag_filter])
84
-
85
- return [sg.get("GroupId") for sg in response.get("SecurityGroups")]
81
+ platform_sg_name = f"platform-{app}-{env}-env-sg"
82
+ copilot_sg_name = f"copilot-{app}-{env}-env"
83
+ tag_filter = {"Name": f"tag:Name", "Values": [copilot_sg_name, platform_sg_name]}
84
+
85
+ filtered_security_groups = self.ec2_client.describe_security_groups(
86
+ Filters=[vpc_filter, tag_filter]
87
+ )
88
+
89
+ platform_security_groups = self._get_matching_security_groups(
90
+ filtered_security_groups, platform_sg_name
91
+ )
92
+
93
+ if platform_security_groups:
94
+ print(
95
+ f"using {platform_security_groups}"
96
+ ) # TODO remove this once decopilotiing has been completed
97
+ return platform_security_groups
98
+
99
+ copilot_security_groups = self._get_matching_security_groups(
100
+ filtered_security_groups, copilot_sg_name
101
+ )
102
+
103
+ print(
104
+ f"using {copilot_security_groups}"
105
+ ) # TODO remove this once decopilotiing has been completed
106
+ return copilot_security_groups
107
+
108
+ def _get_matching_security_groups(
109
+ self, filtered_security_groups: list[dict], security_group_name: str
110
+ ):
111
+ matching_sec_groups = filtered_security_groups.get("SecurityGroups")
112
+ return [
113
+ sg.get("GroupId")
114
+ for sg in matching_sec_groups
115
+ if {"Key": "Name", "Value": security_group_name} in sg.get("Tags", [])
116
+ ]
86
117
 
87
118
  def get_vpc(self, app: str, env: str, vpc_name: str) -> Vpc:
88
119
 
@@ -1,3 +1,4 @@
1
+ from collections import OrderedDict
1
2
  from pathlib import Path
2
3
 
3
4
  import yaml
@@ -29,6 +30,7 @@ class DuplicateKeysException(YamlFileProviderException):
29
30
 
30
31
 
31
32
  class YamlFileProvider:
33
+ @staticmethod
32
34
  def load(path: str) -> dict:
33
35
  """
34
36
  Raises:
@@ -45,15 +47,25 @@ class YamlFileProvider:
45
47
 
46
48
  if not yaml_content:
47
49
  return {}
48
-
49
50
  YamlFileProvider.lint_yaml_for_duplicate_keys(path)
50
51
 
51
52
  return yaml_content
52
53
 
54
+ @staticmethod
53
55
  def write(path: str, contents: dict, comment: str = ""):
54
56
  with open(path, "w") as file:
55
57
  file.write(comment)
56
- yaml.dump(contents, file)
58
+ yaml.add_representer(str, account_number_representer)
59
+ yaml.add_representer(type(None), null_value_representer)
60
+
61
+ yaml.dump(
62
+ contents,
63
+ file,
64
+ canonical=False,
65
+ sort_keys=False,
66
+ default_style=None,
67
+ default_flow_style=False,
68
+ )
57
69
 
58
70
  @staticmethod
59
71
  def lint_yaml_for_duplicate_keys(path):
@@ -72,3 +84,47 @@ class YamlFileProvider:
72
84
  ]
73
85
  if duplicate_keys:
74
86
  raise DuplicateKeysException(",".join(duplicate_keys))
87
+
88
+ @staticmethod
89
+ def remove_empty_keys(config: (dict, OrderedDict)) -> (dict, OrderedDict):
90
+ cleaned = config.__class__()
91
+
92
+ for k, v in config.items():
93
+ if isinstance(v, (dict, OrderedDict)):
94
+ v = YamlFileProvider.remove_empty_keys(v)
95
+ if v not in (None, [], {}, ()):
96
+ cleaned[k] = v
97
+
98
+ return cleaned
99
+
100
+ @staticmethod
101
+ def find_and_replace(config, strings: list, replacements: list):
102
+ if len(strings) != len(replacements):
103
+ raise ValueError("'strings' and 'replacements' must be the same length.")
104
+ if not isinstance(strings, list) or not isinstance(replacements, list):
105
+ raise ValueError("'strings' and 'replacements' must both be lists.")
106
+ if isinstance(config, (dict, OrderedDict)):
107
+ return {
108
+ k: YamlFileProvider.find_and_replace(v, strings, replacements)
109
+ for k, v in config.items()
110
+ }
111
+ elif isinstance(config, list):
112
+ return [
113
+ YamlFileProvider.find_and_replace(item, strings, replacements) for item in config
114
+ ]
115
+ elif isinstance(config, str):
116
+ for s, r in zip(strings, replacements):
117
+ config = config.replace(s, r)
118
+ return config
119
+ else:
120
+ return replacements if config == strings else config
121
+
122
+
123
+ def account_number_representer(dumper, data):
124
+ if data.isdigit():
125
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="'")
126
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=None)
127
+
128
+
129
+ def null_value_representer(dumper, data):
130
+ return dumper.represent_scalar("tag:yaml.org,2002:null", "")
@@ -10,10 +10,10 @@ locals {
10
10
  provider "aws" {
11
11
  region = "eu-west-2"
12
12
  profile = "{{ aws_account }}"
13
- alias = "{{ aws_account }}"
14
- shared_credentials_files = ["~/.aws/config"]
13
+ allowed_account_ids = ["{{ deploy_account_id }}"]
15
14
  }
16
15
 
16
+
17
17
  terraform {
18
18
  required_version = "{{ terraform_version }}"
19
19
  backend "s3" {
@@ -34,7 +34,7 @@ terraform {
34
34
 
35
35
 
36
36
  module "environment-pipelines" {
37
- source = "git::https://github.com/uktrade/terraform-platform-modules.git//environment-pipelines?depth=1&ref={{ terraform_platform_modules_version }}"
37
+ source = "{{ module_source }}"
38
38
 
39
39
  for_each = local.pipelines
40
40
 
@@ -49,4 +49,5 @@ module "environment-pipelines" {
49
49
  slack_channel = each.value.slack_channel
50
50
  trigger_on_push = each.value.trigger_on_push
51
51
  pipeline_to_trigger = lookup(each.value, "pipeline_to_trigger", null)
52
+ pinned_version = {% if pinned_version %}"{{ pinned_version }}"{% else %}null{% endif %}
52
53
  }
@@ -24,3 +24,8 @@
24
24
  Condition:
25
25
  StringEquals:
26
26
  'ssm:ResourceTag/copilot-application': '__all__'
27
+
28
+ - op: add
29
+ path: /Resources/TaskDefinition/Properties/pidMode
30
+ value:
31
+ task
@@ -0,0 +1,103 @@
1
+ import functools
2
+ import time
3
+ from typing import Callable
4
+ from typing import Optional
5
+
6
+ from dbt_platform_helper.platform_exception import PlatformException
7
+ from dbt_platform_helper.providers.io import ClickIOProvider
8
+
9
+ SECONDS_BEFORE_RETRY = 3
10
+ RETRY_MAX_ATTEMPTS = 3
11
+
12
+
13
+ class RetryException(PlatformException):
14
+
15
+ def __init__(
16
+ self, function_name: str, max_attempts: int, original_exception: Optional[Exception] = None
17
+ ):
18
+ message = f"Function: {function_name} failed after {max_attempts} attempts"
19
+ self.original_exception = original_exception
20
+ if original_exception:
21
+ message += f": \n{str(original_exception)}"
22
+ super().__init__(message)
23
+
24
+
25
+ def retry(
26
+ exceptions_to_catch: tuple = (Exception,),
27
+ max_attempts: int = RETRY_MAX_ATTEMPTS,
28
+ delay: float = SECONDS_BEFORE_RETRY,
29
+ raise_custom_exception: bool = True,
30
+ custom_exception: type = RetryException,
31
+ io: ClickIOProvider = ClickIOProvider(),
32
+ ):
33
+ def decorator(func):
34
+ func.__wrapped_by__ = "retry"
35
+
36
+ @functools.wraps(func)
37
+ def wrapper(*args, **kwargs):
38
+ last_exception = None
39
+ for attempt in range(max_attempts):
40
+ try:
41
+ return func(*args, **kwargs)
42
+ except exceptions_to_catch as e:
43
+ last_exception = e
44
+ io.debug(
45
+ f"Attempt {attempt+1}/{max_attempts} for {func.__name__} failed with exception {str(last_exception)}"
46
+ )
47
+ if attempt < max_attempts - 1:
48
+ time.sleep(delay)
49
+ if raise_custom_exception:
50
+ raise custom_exception(func.__name__, max_attempts, last_exception)
51
+ raise last_exception
52
+
53
+ return wrapper
54
+
55
+ return decorator
56
+
57
+
58
+ def wait_until(
59
+ exceptions_to_catch: tuple = (PlatformException,),
60
+ max_attempts: int = RETRY_MAX_ATTEMPTS,
61
+ delay: float = SECONDS_BEFORE_RETRY,
62
+ raise_custom_exception: bool = True,
63
+ custom_exception=RetryException,
64
+ message_on_false="Condition not met",
65
+ io: ClickIOProvider = ClickIOProvider(),
66
+ ):
67
+ """Wrap a function which returns a boolean."""
68
+
69
+ def decorator(func: Callable[..., bool]):
70
+ func.__wrapped_by__ = "wait_until"
71
+
72
+ @functools.wraps(func)
73
+ def wrapper(*args, **kwargs):
74
+ last_exception = None
75
+ for attempt in range(max_attempts):
76
+ try:
77
+ result = func(*args, **kwargs)
78
+ if result:
79
+ return result
80
+ io.debug(
81
+ f"Attempt {attempt+1}/{max_attempts} for {func.__name__} returned falsy"
82
+ )
83
+ except exceptions_to_catch as e:
84
+ last_exception = e
85
+ io.debug(
86
+ f"Attempt {attempt+1}/{max_attempts} for {func.__name__} failed with exception {str(last_exception)}"
87
+ )
88
+
89
+ if attempt < max_attempts - 1:
90
+ time.sleep(delay)
91
+
92
+ if not last_exception: # If func returns false set last_exception
93
+ last_exception = PlatformException(message_on_false)
94
+ if (
95
+ not raise_custom_exception
96
+ ): # Raise last_exception when you don't want custom exception
97
+ raise last_exception
98
+ else:
99
+ raise custom_exception(func.__name__, max_attempts, last_exception)
100
+
101
+ return wrapper
102
+
103
+ return decorator
@@ -59,23 +59,11 @@ class Application:
59
59
  return str(self) == str(other)
60
60
 
61
61
 
62
- def load_application(app=None, default_session=None) -> Application:
62
+ def load_application(app=None, default_session=None, env=None) -> Application:
63
63
  application = Application(app if app else get_application_name())
64
64
  current_session = default_session if default_session else get_aws_session_or_abort()
65
65
 
66
66
  ssm_client = current_session.client("ssm")
67
-
68
- try:
69
- ssm_client.get_parameter(
70
- Name=f"/copilot/applications/{application.name}",
71
- WithDecryption=False,
72
- )
73
- except ssm_client.exceptions.ParameterNotFound:
74
- raise ApplicationNotFoundException(application.name)
75
-
76
- path = f"/copilot/applications/{application.name}/environments"
77
- secrets = get_ssm_secrets(app, None, current_session, path)
78
-
79
67
  sts_client = current_session.client("sts")
80
68
  account_id = sts_client.get_caller_identity()["Account"]
81
69
  sessions = {account_id: current_session}
@@ -86,20 +74,112 @@ def load_application(app=None, default_session=None) -> Application:
86
74
  nesting.
87
75
 
88
76
  e.g.
77
+ - /platform/applications/test/environments/my_env will match.
89
78
  - /copilot/applications/test/environments/my_env will match.
90
79
  - /copilot/applications/test/environments/my_env/addons will not match.
91
80
  """
92
- environment_key_regex = r"^/copilot/applications/{}/environments/[^/]*$".format(
81
+ environment_key_regex = r"^/(copilot|platform)/applications/{}/environments/[^/]*$".format(
93
82
  application.name
94
83
  )
95
84
  return bool(re.match(environment_key_regex, name))
96
85
 
97
- environments = {
86
+ environments_data = []
87
+
88
+ # Try to load all /platform SSM parameters that are present
89
+ env_params = get_ssm_secrets(
90
+ app=app,
91
+ env=None,
92
+ session=current_session,
93
+ path=f"/platform/applications/{application.name}/environments",
94
+ )
95
+
96
+ if env_params:
97
+ for name, value in env_params:
98
+ try:
99
+ param_data = json.loads(value)
100
+ except json.JSONDecodeError:
101
+ continue
102
+
103
+ # Each /platform SSM parameter contains data about all the environments of an application
104
+ if "allEnvironments" in param_data:
105
+ environments_data = param_data["allEnvironments"]
106
+ break # Only need one
107
+ else:
108
+ try:
109
+ # Check that the Copilot application exists
110
+ ssm_client.get_parameter(
111
+ Name=f"/copilot/applications/{application.name}",
112
+ WithDecryption=False,
113
+ )
114
+
115
+ # Legacy /copilot SSM parameters for each environment
116
+ env_params = get_ssm_secrets(
117
+ app, None, current_session, f"/copilot/applications/{application.name}/environments"
118
+ )
119
+
120
+ for name, value in env_params:
121
+ try:
122
+ param_data = json.loads(value)
123
+ except json.JSONDecodeError:
124
+ continue
125
+
126
+ if is_environment_key(name):
127
+ environments_data.append(param_data)
128
+
129
+ except ssm_client.exceptions.ParameterNotFound:
130
+ raise ApplicationNotFoundException(
131
+ application_name=application.name, environment_name=env
132
+ )
133
+
134
+ application.environments = {
98
135
  env["name"]: Environment(env["name"], env["accountID"], sessions)
99
- for env in [json.loads(s[1]) for s in secrets if is_environment_key(s[0])]
136
+ for env in environments_data
100
137
  }
101
- application.environments = environments
102
138
 
139
+ application.services = _load_services(ssm_client, application)
140
+
141
+ return application
142
+
143
+
144
+ def _load_services(ssm_client, application: Application) -> Dict[str, Service]:
145
+ """
146
+ Try to load
147
+ /platform/applications/{app}/environments/{env}/services/{service}
148
+ parameters if present.
149
+
150
+ Otherwise, fall back to legacy /copilot/applications/{app}/components
151
+ parameters.
152
+ """
153
+ services: Dict[str, Service] = {}
154
+
155
+ # Try /platform SSM parameter
156
+ for env_name in application.environments.keys():
157
+ params = dict(
158
+ Path=f"/platform/applications/{application.name}/environments/{env_name}/services",
159
+ Recursive=False,
160
+ WithDecryption=False,
161
+ )
162
+
163
+ while True:
164
+ response = ssm_client.get_parameters_by_path(**params)
165
+ for ssm_param in response.get("Parameters", []):
166
+ try:
167
+ data = json.loads(ssm_param["Value"])
168
+ name = data["name"]
169
+ kind = data["type"]
170
+ services.setdefault(name, Service(name, kind)) # Avoid duplicates
171
+ except (json.JSONDecodeError, KeyError):
172
+ continue
173
+
174
+ if "NextToken" in response:
175
+ params["NextToken"] = response["NextToken"]
176
+ else:
177
+ break
178
+
179
+ if services:
180
+ return services
181
+
182
+ # Fallback to legacy /copilot SSM parameter
103
183
  response = ssm_client.get_parameters_by_path(
104
184
  Path=f"/copilot/applications/{application.name}/components",
105
185
  Recursive=False,
@@ -115,17 +195,17 @@ def load_application(app=None, default_session=None) -> Application:
115
195
  )
116
196
  results.extend(response["Parameters"])
117
197
 
118
- application.services = {
198
+ legacy_services = {
119
199
  svc["name"]: Service(svc["name"], svc["type"])
120
200
  for svc in [json.loads(parameter["Value"]) for parameter in results]
121
201
  }
122
202
 
123
- return application
203
+ return legacy_services
124
204
 
125
205
 
126
206
  def get_application_name(abort=abort_with_error):
127
207
  if Path(PLATFORM_CONFIG_FILE).exists():
128
- config = ConfigProvider()
208
+ config = ConfigProvider(installed_version_provider="N/A")
129
209
  try:
130
210
  app_config = config.load_unvalidated_config_file()
131
211
  return app_config["application"]
@@ -142,7 +222,24 @@ class ApplicationException(PlatformException):
142
222
 
143
223
 
144
224
  class ApplicationNotFoundException(ApplicationException):
145
- def __init__(self, application_name: str):
225
+ def __init__(self, application_name: str, environment_name: str):
226
+ super().__init__(
227
+ f"""The account "{os.environ.get("AWS_PROFILE")}" does not contain the application "{application_name}".
228
+ Please ensure that the environment variable "AWS_PROFILE" is set correctly. If the issue persists, verify that one of the following AWS SSM parameters exists:
229
+ - /platform/applications/{application_name}/environments/{environment_name}
230
+ - /copilot/applications/{application_name}"""
231
+ )
232
+
233
+
234
+ class ApplicationServiceNotFoundException(ApplicationException):
235
+ def __init__(self, application_name: str, svc_name: str):
236
+ super().__init__(
237
+ f"""The service {svc_name} was not found in the application {application_name}. It either does not exist, or has not been deployed."""
238
+ )
239
+
240
+
241
+ class ApplicationEnvironmentNotFoundException(ApplicationException):
242
+ def __init__(self, application_name: str, environment: str):
146
243
  super().__init__(
147
- f"""The account "{os.environ.get("AWS_PROFILE")}" does not contain the application "{application_name}"; ensure you have set the environment variable "AWS_PROFILE" correctly."""
244
+ f"""The environment "{environment}" either does not exist or has not been deployed for the application {application_name}."""
148
245
  )