dbt-platform-helper 13.2.0__py3-none-any.whl → 13.4.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.

Potentially problematic release.


This version of dbt-platform-helper might be problematic. Click here for more details.

Files changed (37) hide show
  1. dbt_platform_helper/COMMANDS.md +7 -2
  2. dbt_platform_helper/commands/codebase.py +29 -6
  3. dbt_platform_helper/commands/config.py +12 -314
  4. dbt_platform_helper/commands/copilot.py +10 -6
  5. dbt_platform_helper/commands/database.py +17 -9
  6. dbt_platform_helper/commands/environment.py +2 -3
  7. dbt_platform_helper/domain/codebase.py +68 -25
  8. dbt_platform_helper/domain/config.py +345 -0
  9. dbt_platform_helper/domain/copilot.py +155 -157
  10. dbt_platform_helper/domain/versioning.py +48 -7
  11. dbt_platform_helper/providers/aws/__init__.py +0 -0
  12. dbt_platform_helper/providers/aws/exceptions.py +12 -2
  13. dbt_platform_helper/providers/aws/sso_auth.py +61 -0
  14. dbt_platform_helper/providers/config.py +2 -1
  15. dbt_platform_helper/providers/config_validator.py +15 -13
  16. dbt_platform_helper/providers/ecr.py +64 -7
  17. dbt_platform_helper/providers/io.py +2 -2
  18. dbt_platform_helper/providers/parameter_store.py +47 -0
  19. dbt_platform_helper/providers/platform_config_schema.py +17 -0
  20. dbt_platform_helper/providers/semantic_version.py +15 -88
  21. dbt_platform_helper/providers/terraform_manifest.py +1 -0
  22. dbt_platform_helper/providers/version.py +82 -24
  23. dbt_platform_helper/providers/version_status.py +80 -0
  24. dbt_platform_helper/utils/aws.py +0 -135
  25. dbt_platform_helper/utils/git.py +3 -1
  26. dbt_platform_helper/utils/tool_versioning.py +0 -84
  27. {dbt_platform_helper-13.2.0.dist-info → dbt_platform_helper-13.4.0.dist-info}/METADATA +2 -2
  28. {dbt_platform_helper-13.2.0.dist-info → dbt_platform_helper-13.4.0.dist-info}/RECORD +32 -32
  29. {dbt_platform_helper-13.2.0.dist-info → dbt_platform_helper-13.4.0.dist-info}/WHEEL +1 -1
  30. platform_helper.py +1 -1
  31. dbt_platform_helper/templates/svc/manifest-backend.yml +0 -69
  32. dbt_platform_helper/templates/svc/manifest-public.yml +0 -109
  33. dbt_platform_helper/utils/cloudfoundry.py +0 -14
  34. dbt_platform_helper/utils/files.py +0 -53
  35. dbt_platform_helper/utils/manifests.py +0 -18
  36. {dbt_platform_helper-13.2.0.dist-info → dbt_platform_helper-13.4.0.dist-info}/LICENSE +0 -0
  37. {dbt_platform_helper-13.2.0.dist-info → dbt_platform_helper-13.4.0.dist-info}/entry_points.txt +0 -0
@@ -3,20 +3,22 @@ import stat
3
3
  import subprocess
4
4
  from collections.abc import Callable
5
5
  from pathlib import Path
6
+ from typing import Tuple
6
7
 
7
8
  import requests
8
9
  import yaml
9
10
  from boto3 import Session
10
11
 
11
12
  from dbt_platform_helper.platform_exception import PlatformException
13
+ from dbt_platform_helper.providers.ecr import ECRProvider
12
14
  from dbt_platform_helper.providers.files import FileProvider
13
15
  from dbt_platform_helper.providers.io import ClickIOProvider
16
+ from dbt_platform_helper.providers.parameter_store import ParameterStore
14
17
  from dbt_platform_helper.utils.application import Application
15
18
  from dbt_platform_helper.utils.application import (
16
19
  ApplicationEnvironmentNotFoundException,
17
20
  )
18
21
  from dbt_platform_helper.utils.application import load_application
19
- from dbt_platform_helper.utils.aws import check_image_exists
20
22
  from dbt_platform_helper.utils.aws import get_aws_session_or_abort
21
23
  from dbt_platform_helper.utils.aws import get_build_url_from_arn
22
24
  from dbt_platform_helper.utils.aws import get_build_url_from_pipeline_execution_id
@@ -32,10 +34,11 @@ from dbt_platform_helper.utils.template import setup_templates
32
34
  class Codebase:
33
35
  def __init__(
34
36
  self,
37
+ parameter_provider: ParameterStore,
35
38
  io: ClickIOProvider = ClickIOProvider(),
36
39
  load_application: Callable[[str], Application] = load_application,
37
40
  get_aws_session_or_abort: Callable[[str], Session] = get_aws_session_or_abort,
38
- check_image_exists: Callable[[str], str] = check_image_exists,
41
+ ecr_provider: ECRProvider = ECRProvider(),
39
42
  get_image_build_project: Callable[[str], str] = get_image_build_project,
40
43
  get_manual_release_pipeline: Callable[[str], str] = get_manual_release_pipeline,
41
44
  get_build_url_from_arn: Callable[[str], str] = get_build_url_from_arn,
@@ -50,10 +53,11 @@ class Codebase:
50
53
  check_if_commit_exists: Callable[[str], str] = check_if_commit_exists,
51
54
  run_subprocess: Callable[[str], str] = subprocess.run,
52
55
  ):
56
+ self.parameter_provider = parameter_provider
53
57
  self.io = io
54
58
  self.load_application = load_application
55
59
  self.get_aws_session_or_abort = get_aws_session_or_abort
56
- self.check_image_exists = check_image_exists
60
+ self.ecr_provider = ecr_provider
57
61
  self.get_image_build_project = get_image_build_project
58
62
  self.get_manual_release_pipeline = get_manual_release_pipeline
59
63
  self.get_build_url_from_arn = get_build_url_from_arn
@@ -151,31 +155,54 @@ class Codebase:
151
155
 
152
156
  raise ApplicationDeploymentNotTriggered(codebase)
153
157
 
154
- def deploy(self, app, env, codebase, commit):
158
+ def deploy(
159
+ self,
160
+ app: str,
161
+ env: str,
162
+ codebase: str,
163
+ commit: str = None,
164
+ tag: str = None,
165
+ branch: str = None,
166
+ ):
155
167
  """Trigger a CodePipeline pipeline based deployment."""
156
- session = self.get_aws_session_or_abort()
157
168
 
158
- application = self.load_application(app, default_session=session)
159
- if not application.environments.get(env):
160
- raise ApplicationEnvironmentNotFoundException(application.name, env)
169
+ self._validate_reference_flags(commit, tag, branch)
161
170
 
162
- self.check_image_exists(session, application, codebase, commit)
171
+ application, session = self._populate_application_values(app, env)
163
172
 
164
- codepipeline_client = session.client("codepipeline")
173
+ image_ref = None
174
+ if commit:
175
+ image_ref = f"commit-{commit[0:7]}"
176
+ elif tag:
177
+ image_ref = f"tag-{tag}"
178
+ elif branch:
179
+ image_ref = f"branch-{branch}"
180
+ image_details = self.ecr_provider.get_image_details(application, codebase, image_ref)
181
+ image_ref = self.ecr_provider.find_commit_tag(image_details, image_ref)
165
182
 
183
+ codepipeline_client = session.client("codepipeline")
166
184
  pipeline_name = self.get_manual_release_pipeline(codepipeline_client, app, codebase)
167
185
 
186
+ corresponding_to = ""
187
+ if tag:
188
+ corresponding_to = f"(corresponding to tag {tag}) "
189
+ elif branch:
190
+ corresponding_to = f"(corresponding to branch {branch}) "
191
+
192
+ confirmation_message = f'\nYou are about to deploy "{app}" for "{codebase}" with image reference "{image_ref}" {corresponding_to}to the "{env}" environment using the "{pipeline_name}" deployment pipeline. Do you want to continue?'
193
+ build_options = {
194
+ "name": pipeline_name,
195
+ "variables": [
196
+ {"name": "ENVIRONMENT", "value": env},
197
+ {"name": "IMAGE_TAG", "value": image_ref},
198
+ ],
199
+ }
200
+
168
201
  build_url = self.__start_pipeline_execution_with_confirmation(
169
202
  codepipeline_client,
170
203
  self.get_build_url_from_pipeline_execution_id,
171
- f'You are about to deploy "{app}" for "{codebase}" with commit "{commit}" to the "{env}" environment using the "{pipeline_name}" deployment pipeline. Do you want to continue?',
172
- {
173
- "name": pipeline_name,
174
- "variables": [
175
- {"name": "ENVIRONMENT", "value": env},
176
- {"name": "IMAGE_TAG", "value": f"commit-{commit}"},
177
- ],
178
- },
204
+ confirmation_message,
205
+ build_options,
179
206
  )
180
207
 
181
208
  if build_url:
@@ -186,13 +213,31 @@ class Codebase:
186
213
 
187
214
  raise ApplicationDeploymentNotTriggered(codebase)
188
215
 
216
+ def _validate_reference_flags(self, commit: str, tag: str, branch: str):
217
+ provided = [ref for ref in [commit, tag, branch] if ref]
218
+
219
+ if len(provided) == 0:
220
+ self.io.abort_with_error(
221
+ "To deploy, you must provide one of the options --commit, --tag or --branch."
222
+ )
223
+ elif len(provided) > 1:
224
+ self.io.abort_with_error(
225
+ "You have provided more than one of the --tag, --branch and --commit options but these are mutually exclusive. Please provide only one of these options."
226
+ )
227
+
228
+ def _populate_application_values(self, app: str, env: str) -> Tuple[Application, Session]:
229
+ session = self.get_aws_session_or_abort()
230
+ application = self.load_application(app, default_session=session)
231
+ if not application.environments.get(env):
232
+ raise ApplicationEnvironmentNotFoundException(application.name, env)
233
+ return application, session
234
+
189
235
  def list(self, app: str, with_images: bool):
190
236
  """List available codebases for the application."""
191
237
  session = self.get_aws_session_or_abort()
192
238
  application = self.load_application(app, session)
193
- ssm_client = session.client("ssm")
194
239
  ecr_client = session.client("ecr")
195
- codebases = self.__get_codebases(application, ssm_client)
240
+ codebases = self.__get_codebases(application, session.client("ssm"))
196
241
 
197
242
  self.io.info("The following codebases are available:")
198
243
 
@@ -209,11 +254,9 @@ class Codebase:
209
254
  self.io.info("")
210
255
 
211
256
  def __get_codebases(self, application, ssm_client):
212
- parameters = ssm_client.get_parameters_by_path(
213
- Path=f"/copilot/applications/{application.name}/codebases",
214
- Recursive=True,
215
- )["Parameters"]
216
-
257
+ parameters = self.parameter_provider.get_ssm_parameters_by_path(
258
+ f"/copilot/applications/{application.name}/codebases"
259
+ )
217
260
  codebases = [json.loads(p["Value"]) for p in parameters]
218
261
 
219
262
  if not codebases:
@@ -0,0 +1,345 @@
1
+ import os
2
+ import re
3
+ import webbrowser
4
+ from pathlib import Path
5
+ from typing import Dict
6
+
7
+ from prettytable import PrettyTable
8
+
9
+ from dbt_platform_helper.constants import PLATFORM_CONFIG_FILE
10
+ from dbt_platform_helper.domain.versioning import AWSVersioning
11
+ from dbt_platform_helper.domain.versioning import CopilotVersioning
12
+ from dbt_platform_helper.domain.versioning import PlatformHelperVersioning
13
+ from dbt_platform_helper.platform_exception import PlatformException
14
+ from dbt_platform_helper.providers.aws.sso_auth import SSOAuthProvider
15
+ from dbt_platform_helper.providers.io import ClickIOProvider
16
+ from dbt_platform_helper.providers.semantic_version import (
17
+ IncompatibleMajorVersionException,
18
+ )
19
+ from dbt_platform_helper.providers.semantic_version import SemanticVersion
20
+ from dbt_platform_helper.providers.validation import ValidationException
21
+ from dbt_platform_helper.providers.version_status import PlatformHelperVersionStatus
22
+ from dbt_platform_helper.providers.version_status import VersionStatus
23
+
24
+ yes = "\033[92m✔\033[0m"
25
+ no = "\033[91m✖\033[0m"
26
+ maybe = "\033[93m?\033[0m"
27
+
28
+ RECOMMENDATIONS = {
29
+ "dbt-platform-helper-upgrade": (
30
+ "Upgrade dbt-platform-helper to version {version} `pip install "
31
+ "--upgrade dbt-platform-helper=={version}`."
32
+ ),
33
+ "dbt-platform-helper-upgrade-note": (
34
+ "Post upgrade, run `platform-helper copilot make-addons` to " "update your addon templates."
35
+ ),
36
+ "generic-tool-upgrade": "Upgrade {tool} to version {version}.",
37
+ "install-copilot": "Install AWS Copilot https://aws.github.io/copilot-cli/",
38
+ "install-aws": "Install AWS CLI https://aws.amazon.com/cli/",
39
+ }
40
+
41
+ AWS_CONFIG = """
42
+ #
43
+ # uktrade
44
+ #
45
+
46
+ [sso-session uktrade]
47
+ sso_start_url = https://uktrade.awsapps.com/start#/
48
+ sso_region = eu-west-2
49
+ sso_registration_scopes = sso:account:access
50
+
51
+ [default]
52
+ sso_session = uktrade
53
+ region = eu-west-2
54
+ output = json
55
+
56
+ """
57
+
58
+
59
+ class NoDeploymentRepoConfigException(PlatformException):
60
+ def __init__(self):
61
+ super().__init__("Could not find a deployment repository, no checks to run.")
62
+
63
+
64
+ # TODO move to generic location so it can be reused
65
+ class NoPlatformConfigException(PlatformException):
66
+ def __init__(self):
67
+ super().__init__(
68
+ f"`platform-config.yml` is missing. "
69
+ "Please check it exists and you are in the root directory of your deployment project."
70
+ )
71
+
72
+
73
+ class Config:
74
+
75
+ def __init__(
76
+ self,
77
+ io: ClickIOProvider = ClickIOProvider(),
78
+ platform_helper_versioning: PlatformHelperVersioning = PlatformHelperVersioning(),
79
+ aws_versioning: AWSVersioning = AWSVersioning(),
80
+ copilot_versioning: CopilotVersioning = CopilotVersioning(),
81
+ sso: SSOAuthProvider = None,
82
+ ):
83
+ self.oidc_app = None
84
+ self.io = io
85
+ self.platform_helper_versioning = platform_helper_versioning
86
+ self.aws_versioning = aws_versioning
87
+ self.copilot_versioning = copilot_versioning
88
+ self.sso = sso or SSOAuthProvider()
89
+ self.SSO_START_URL = "https://uktrade.awsapps.com/start"
90
+
91
+ def validate(self):
92
+ if not Path("copilot").exists():
93
+ raise NoDeploymentRepoConfigException()
94
+ if not Path(PLATFORM_CONFIG_FILE).exists():
95
+ raise NoPlatformConfigException()
96
+
97
+ self.io.debug("\nDetected a deployment repository\n")
98
+ platform_helper_version_status = self.platform_helper_versioning._get_version_status(
99
+ include_project_versions=True
100
+ )
101
+ self.io.process_messages(platform_helper_version_status.validate())
102
+ aws_version_status = self.aws_versioning.get_version_status()
103
+ copilot_version_status = self.copilot_versioning.get_version_status()
104
+
105
+ self._check_tool_versions(
106
+ platform_helper_version_status, aws_version_status, copilot_version_status
107
+ )
108
+
109
+ compatible = self._check_addon_versions(platform_helper_version_status)
110
+
111
+ exit(0 if compatible else 1)
112
+
113
+ def generate_aws(self, file_path):
114
+ self.oidc_app = self._create_oidc_application()
115
+ verification_url, device_code = self._get_device_code(self.oidc_app)
116
+
117
+ if self.io.confirm(
118
+ "You are about to be redirected to a verification page. You will need to complete sign-in before returning to the command line. Do you want to continue?",
119
+ ):
120
+ webbrowser.open(verification_url)
121
+
122
+ if self.io.confirm(
123
+ "Have you completed the sign-in process in your browser?",
124
+ ):
125
+ access_token = self.sso.create_access_token(
126
+ client_id=self.oidc_app[0],
127
+ client_secret=self.oidc_app[1],
128
+ device_code=device_code,
129
+ )
130
+
131
+ aws_config_path = os.path.expanduser(file_path)
132
+
133
+ if self.io.confirm(
134
+ f"This command is destructive and will overwrite file contents at {file_path}. Are you sure you want to continue?"
135
+ ):
136
+ with open(aws_config_path, "w") as config_file:
137
+ config_file.write(AWS_CONFIG)
138
+
139
+ for account in self._retrieve_aws_accounts(access_token):
140
+ config_file.write(f"[profile {account['accountName']}]\n")
141
+ config_file.write("sso_session = uktrade\n")
142
+ config_file.write(f"sso_account_id = {account['accountId']}\n")
143
+ config_file.write("sso_role_name = AdministratorAccess\n")
144
+ config_file.write("region = eu-west-2\n")
145
+ config_file.write("output = json\n")
146
+ config_file.write("\n")
147
+
148
+ def _create_oidc_application(self):
149
+ self.io.debug("Creating temporary AWS SSO OIDC application")
150
+ client_id, client_secret = self.sso.register(
151
+ client_name="platform-helper",
152
+ client_type="public",
153
+ )
154
+ return client_id, client_secret
155
+
156
+ def _get_device_code(self, oidc_application):
157
+ self.io.debug("Initiating device code flow")
158
+ url, device_code = self.sso.start_device_authorization(
159
+ client_id=oidc_application[0],
160
+ client_secret=oidc_application[1],
161
+ start_url=self.SSO_START_URL,
162
+ )
163
+
164
+ return url, device_code
165
+
166
+ def _retrieve_aws_accounts(self, aws_sso_token):
167
+ accounts_list = self.sso.list_accounts(
168
+ access_token=aws_sso_token,
169
+ max_results=100,
170
+ )
171
+ return accounts_list
172
+
173
+ def _add_version_status_row(
174
+ self, table: PrettyTable, header: str, version_status: VersionStatus
175
+ ):
176
+ table.add_row(
177
+ [
178
+ header,
179
+ str(version_status.installed),
180
+ str(version_status.latest),
181
+ no if version_status.is_outdated() else yes,
182
+ ]
183
+ )
184
+
185
+ def _check_tool_versions(
186
+ self,
187
+ platform_helper_version_status: PlatformHelperVersionStatus,
188
+ aws_version_status: VersionStatus,
189
+ copilot_version_status: VersionStatus,
190
+ ):
191
+ self.io.debug("Checking tooling versions...")
192
+
193
+ recommendations = {}
194
+
195
+ if copilot_version_status.installed is None:
196
+ recommendations["install-copilot"] = RECOMMENDATIONS["install-copilot"]
197
+
198
+ if aws_version_status.installed is None:
199
+ recommendations["install-aws"] = RECOMMENDATIONS["install-aws"]
200
+
201
+ tool_versions_table = PrettyTable()
202
+ tool_versions_table.field_names = [
203
+ "Tool",
204
+ "Local version",
205
+ "Released version",
206
+ "Running latest?",
207
+ ]
208
+ tool_versions_table.align["Tool"] = "l"
209
+
210
+ self._add_version_status_row(tool_versions_table, "aws", aws_version_status)
211
+ self._add_version_status_row(tool_versions_table, "copilot", copilot_version_status)
212
+ self._add_version_status_row(
213
+ tool_versions_table, "dbt-platform-helper", platform_helper_version_status
214
+ )
215
+
216
+ self.io.info(tool_versions_table)
217
+
218
+ if aws_version_status.is_outdated() and "install-aws" not in recommendations:
219
+ recommendations["aws-upgrade"] = RECOMMENDATIONS["generic-tool-upgrade"].format(
220
+ tool="AWS CLI",
221
+ version=str(aws_version_status.latest),
222
+ )
223
+
224
+ if copilot_version_status.is_outdated() and "install-copilot" not in recommendations:
225
+ recommendations["copilot-upgrade"] = RECOMMENDATIONS["generic-tool-upgrade"].format(
226
+ tool="AWS Copilot",
227
+ version=str(copilot_version_status.latest),
228
+ )
229
+
230
+ if platform_helper_version_status.is_outdated():
231
+ recommendations["dbt-platform-helper-upgrade"] = RECOMMENDATIONS[
232
+ "dbt-platform-helper-upgrade"
233
+ ].format(version=str(platform_helper_version_status.latest))
234
+ recommendations["dbt-platform-helper-upgrade-note"] = RECOMMENDATIONS[
235
+ "dbt-platform-helper-upgrade-note"
236
+ ]
237
+
238
+ self._render_recommendations(recommendations)
239
+
240
+ def _check_addon_versions(self, platform_helper_versions: PlatformHelperVersionStatus) -> bool:
241
+
242
+ self.io.debug("Checking addons templates versions...")
243
+
244
+ compatible = True
245
+ recommendations = {}
246
+
247
+ local_version = platform_helper_versions.installed
248
+ latest_release = platform_helper_versions.latest
249
+
250
+ addons_templates_table = PrettyTable()
251
+ addons_templates_table.field_names = [
252
+ "Addons Template File",
253
+ "Generated with",
254
+ "Compatible with local?",
255
+ "Compatible with latest?",
256
+ ]
257
+ addons_templates_table.align["Addons Template File"] = "l"
258
+
259
+ addons_templates = list(Path("./copilot").glob("**/addons/*"))
260
+ # Sort by template file path
261
+ addons_templates.sort(key=lambda e: str(e))
262
+ # Bring environment addons to the top
263
+ addons_templates.sort(key=lambda e: "environments/" not in str(e))
264
+
265
+ for template_file in addons_templates:
266
+ generated_with_version = maybe
267
+ local_compatible_symbol = yes
268
+ latest_compatible_symbol = yes
269
+
270
+ generated_with_version = None
271
+
272
+ try:
273
+ generated_with_version = self.__get_template_generated_with_version(
274
+ str(template_file.resolve())
275
+ )
276
+ except ValidationException:
277
+ local_compatible_symbol = maybe
278
+ compatible = False
279
+ recommendations["dbt-platform-helper-upgrade"] = RECOMMENDATIONS[
280
+ "dbt-platform-helper-upgrade"
281
+ ].format(version=latest_release)
282
+ recommendations["dbt-platform-helper-upgrade-note"] = RECOMMENDATIONS[
283
+ "dbt-platform-helper-upgrade-note"
284
+ ]
285
+
286
+ try:
287
+ local_version.validate_compatibility_with(generated_with_version)
288
+ except IncompatibleMajorVersionException:
289
+ local_compatible_symbol = no
290
+ compatible = False
291
+ recommendations["dbt-platform-helper-upgrade"] = RECOMMENDATIONS[
292
+ "dbt-platform-helper-upgrade"
293
+ ].format(version=latest_release)
294
+ recommendations["dbt-platform-helper-upgrade-note"] = RECOMMENDATIONS[
295
+ "dbt-platform-helper-upgrade-note"
296
+ ]
297
+ except ValidationException:
298
+ local_compatible_symbol = maybe
299
+ compatible = False
300
+
301
+ try:
302
+ latest_release.validate_compatibility_with(generated_with_version)
303
+ except IncompatibleMajorVersionException:
304
+ latest_compatible_symbol = no
305
+ compatible = False
306
+ except ValidationException:
307
+ latest_compatible_symbol = maybe
308
+ compatible = False
309
+
310
+ addons_templates_table.add_row(
311
+ [
312
+ template_file.relative_to("."),
313
+ (maybe if latest_compatible_symbol is maybe else str(generated_with_version)),
314
+ local_compatible_symbol,
315
+ latest_compatible_symbol,
316
+ ]
317
+ )
318
+
319
+ self.io.info(addons_templates_table)
320
+ self._render_recommendations(recommendations)
321
+
322
+ return compatible
323
+
324
+ def _render_recommendations(self, recommendations: Dict[str, str]):
325
+ if recommendations:
326
+ self.io.info("\nRecommendations:\n", bold=True)
327
+
328
+ for name, recommendation in recommendations.items():
329
+ if name.endswith("-note"):
330
+ continue
331
+ self.io.info(f" - {recommendation}")
332
+ if recommendations.get(f"{name}-note", False):
333
+ self.io.info(f" {recommendations.get(f'{name}-note')}")
334
+
335
+ self.io.info("")
336
+
337
+ def __get_template_generated_with_version(self, template_file_path: str) -> SemanticVersion:
338
+ try:
339
+ template_contents = Path(template_file_path).read_text()
340
+ template_version = re.search(
341
+ r"# Generated by platform-helper ([v.\-0-9]+)", template_contents
342
+ ).group(1)
343
+ return SemanticVersion.from_string(template_version)
344
+ except (IndexError, AttributeError):
345
+ raise ValidationException(f"Template {template_file_path} has no version information")