leverage 2.2.0rc3__tar.gz → 2.2.0rc5__tar.gz

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 (26) hide show
  1. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/PKG-INFO +1 -1
  2. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/__init__.py +1 -1
  3. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/_internals.py +13 -0
  4. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/leverage.py +4 -0
  5. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/modules/auth.py +176 -3
  6. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/modules/aws.py +5 -23
  7. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/modules/credentials.py +13 -16
  8. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/modules/kubectl.py +30 -50
  9. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/modules/runner.py +19 -10
  10. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/modules/tf.py +19 -48
  11. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/modules/tfautomv.py +6 -11
  12. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/modules/tfrunner.py +15 -7
  13. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/modules/utils.py +12 -9
  14. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/path.py +21 -19
  15. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/pyproject.toml +1 -1
  16. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/LICENSE.txt +0 -0
  17. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/README.md +0 -0
  18. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/_backend_config.py +0 -0
  19. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/_parsing.py +0 -0
  20. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/_utils.py +0 -0
  21. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/conf.py +0 -0
  22. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/logger.py +0 -0
  23. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/modules/__init__.py +0 -0
  24. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/modules/project.py +0 -0
  25. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/modules/run.py +0 -0
  26. {leverage-2.2.0rc3 → leverage-2.2.0rc5}/leverage/tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: leverage
3
- Version: 2.2.0rc3
3
+ Version: 2.2.0rc5
4
4
  Summary: Binbash Leverage Command-Line tool.
5
5
  Home-page: https://github.com/binbashar/leverage
6
6
  License: MIT
@@ -4,7 +4,7 @@
4
4
 
5
5
  # pylint: disable=wrong-import-position
6
6
 
7
- __version__ = "2.2.0rc3"
7
+ __version__ = "2.2.0rc5"
8
8
 
9
9
  import sys
10
10
  from shutil import which
@@ -30,6 +30,7 @@ class State:
30
30
  self.config = None
31
31
  self.paths = None
32
32
  self.runner = None
33
+ self.environment = None
33
34
 
34
35
  @property
35
36
  def verbosity(self):
@@ -65,3 +66,15 @@ def pass_paths(command):
65
66
  return command(ctx.obj.paths, *args, **kwargs)
66
67
 
67
68
  return new_command
69
+
70
+
71
+ def pass_environment(command):
72
+ """Decorator to pass the current environment to the command."""
73
+
74
+ @wraps(command)
75
+ def new_command(*args, **kwargs):
76
+ ctx = click.get_current_context()
77
+
78
+ return command(ctx.obj.environment, *args, **kwargs)
79
+
80
+ return new_command
@@ -28,6 +28,10 @@ def leverage(context, state, verbose):
28
28
  except NotARepositoryError:
29
29
  return
30
30
  state.paths = PathsHandler(state.config)
31
+ state.environment = {
32
+ "AWS_SHARED_CREDENTIALS_FILE": str(state.paths.aws_credentials_file),
33
+ "AWS_CONFIG_FILE": str(state.paths.aws_config_file),
34
+ }
31
35
 
32
36
 
33
37
  # Add modules to leverage
@@ -1,12 +1,16 @@
1
1
  import time
2
2
  import json
3
3
  from pathlib import Path
4
- from datetime import datetime
4
+ from datetime import datetime, timedelta
5
+ from functools import wraps
5
6
  from configparser import NoSectionError, NoOptionError
6
7
 
7
8
  import boto3
8
- from botocore.exceptions import ClientError
9
+ import click
10
+ from dateutil.tz import tzutc
9
11
  from configupdater import ConfigUpdater
12
+ from botocore.session import get_session
13
+ from botocore.exceptions import ClientError
10
14
 
11
15
  from leverage import logger
12
16
  from leverage.path import PathsHandler
@@ -24,7 +28,7 @@ def get_layer_profile(raw_profile: str, config_updater: ConfigUpdater, tf_profil
24
28
  raise SkipProfile
25
29
 
26
30
  # if it is exactly that variable, we already know the layer profile is tf_profile
27
- layer_profile = tf_profile if raw_profile in ("${var.profile}", "each.value.profile") else None
31
+ layer_profile = tf_profile if raw_profile in ("var.profile", "each.value.profile") else None
28
32
 
29
33
  # replace variables with their corresponding values
30
34
  raw = (
@@ -94,6 +98,50 @@ def get_sso_access_token(sso_token_file: Path) -> str:
94
98
  return json.loads(sso_token_file.read_text())["accessToken"]
95
99
 
96
100
 
101
+ def _perform_authentication(paths: PathsHandler):
102
+ """Perform authentication checks and credential refresh.
103
+
104
+ This function contains the core authentication logic that checks for SSO or MFA
105
+ configuration and refreshes credentials accordingly. Only authenticates when
106
+ in a layer location.
107
+
108
+ Args:
109
+ paths: PathsHandler instance containing project paths and configuration
110
+ """
111
+ if paths.get_location_type() == "layer":
112
+ if paths.common_conf.get("sso_enabled", False):
113
+ check_sso_token(paths)
114
+ refresh_layer_credentials(paths)
115
+ elif paths.mfa_enabled:
116
+ refresh_layer_credentials_mfa(paths)
117
+
118
+
119
+ def authenticate(command):
120
+ """Decorator to require authentication before running a command.
121
+
122
+ This decorator extracts the PathsHandler from the Click context and performs
123
+ authentication checks before executing the wrapped command. It handles both SSO
124
+ and MFA authentication based on the project configuration.
125
+
126
+ Usage:
127
+ @click.command()
128
+ @authenticate
129
+ @pass_paths
130
+ @pass_runner
131
+ def some_command(tf: TFRunner, paths: PathsHandler, args):
132
+ # command logic
133
+ """
134
+
135
+ @wraps(command)
136
+ def new_command(*args, **kwargs):
137
+ ctx = click.get_current_context()
138
+ paths = ctx.obj.paths
139
+ _perform_authentication(paths)
140
+ return command(*args, **kwargs)
141
+
142
+ return new_command
143
+
144
+
97
145
  def check_sso_token(paths: PathsHandler):
98
146
  """Check for the existence and validity of the SSO token to be used to get credentials."""
99
147
 
@@ -203,3 +251,128 @@ def refresh_layer_credentials(paths: PathsHandler):
203
251
  },
204
252
  )
205
253
  logger.info(f"Credentials for {account_name} account written successfully.")
254
+
255
+
256
+ def refresh_layer_credentials_mfa(paths: PathsHandler):
257
+ tf_profile, raw_profiles = get_profiles(paths)
258
+ config_updater = ConfigUpdater()
259
+ config_updater.read(paths.aws_config_file)
260
+
261
+ # Create STS client with source profile credentials
262
+ session = get_session()
263
+ session.set_config_variable("credentials_file", paths.aws_credentials_file.as_posix())
264
+ session.set_config_variable("config_file", paths.aws_config_file.as_posix())
265
+
266
+ for raw_profile in raw_profiles:
267
+ if "local." in raw_profile:
268
+ # ignore values referencing to local variables
269
+ # we will search for profiles directly in locals.tf instead
270
+ continue
271
+
272
+ # if it is exactly that variable, we already know the layer profile is tf_profile
273
+ layer_profile = tf_profile if raw_profile in ("var.profile", "each.value.profile") else None
274
+
275
+ # replace variables with their corresponding values
276
+ profile_name = (
277
+ raw_profile.replace("${var.profile}", tf_profile)
278
+ .replace("${var.project}", paths.project)
279
+ .replace("each.value.profile", tf_profile)
280
+ )
281
+
282
+ # if layer_profile wasn't set, use profile_name
283
+ if layer_profile is None:
284
+ layer_profile = profile_name
285
+
286
+ logger.info(f"Attempting to get temporary credentials for {profile_name} profile.")
287
+ if profile := config_updater.get_section(f"profile {profile_name}-mfa"):
288
+ role_arn = profile.get("role_arn").value
289
+ mfa_serial = profile.get("mfa_serial").value
290
+ source_profile = profile.get("source_profile").value
291
+ else:
292
+ raise ExitError(
293
+ 40,
294
+ f"Credentials for profile {profile_name} have not been properly configured. Please check your configuration.\n"
295
+ f"Check the following link for possible solutions: https://leverage.binbash.co/user-guide/troubleshooting/credentials/",
296
+ )
297
+
298
+ if not paths.aws_cache_dir.exists():
299
+ paths.aws_cache_dir.mkdir(parents=True)
300
+
301
+ cache_file = paths.aws_cache_dir / profile_name
302
+ if cache_file.exists():
303
+ logger.debug(f"Found cached credentials in {cache_file}.")
304
+ cached_credentials = json.loads(cache_file.read_text())
305
+
306
+ expiration = datetime.strptime(cached_credentials.get("Expiration"), "%Y-%m-%dT%H:%M:%SZ").replace(
307
+ tzinfo=tzutc()
308
+ )
309
+ renewal = datetime.now(tzutc()) + timedelta(seconds=(30 * 60))
310
+ if renewal < expiration:
311
+ logger.info("Using cached credentials.")
312
+ continue
313
+
314
+ else:
315
+ logger.debug("No cached credentials found.")
316
+
317
+ client_session = boto3.Session(botocore_session=session, profile_name=source_profile)
318
+ client = client_session.client("sts")
319
+ credentials = None
320
+ for _ in range(3):
321
+ try:
322
+ mfa_token_code = click.prompt("Enter MFA token code", type=str)
323
+ except click.exceptions.Abort:
324
+ raise ExitError(1, "Aborted by user.")
325
+
326
+ try:
327
+ credentials = client.assume_role(
328
+ RoleArn=role_arn,
329
+ RoleSessionName=f"leverage-{profile_name}",
330
+ SerialNumber=mfa_serial,
331
+ TokenCode=mfa_token_code,
332
+ )
333
+ credentials = credentials["Credentials"]
334
+ credentials["Expiration"] = credentials["Expiration"].strftime("%Y-%m-%dT%H:%M:%SZ")
335
+ cache_file.write_text(json.dumps(credentials))
336
+ break
337
+
338
+ except ClientError as error:
339
+ if "invalid MFA" in error.response["Error"]["Message"]:
340
+ logger.error("Unable to get valid credentials. Please try again.")
341
+ continue
342
+ elif error.response["Error"]["Code"] == "AccessDeniedException":
343
+ raise ExitError(
344
+ 40,
345
+ f"User does not have permission to assume role [bold]{role_arn}[/bold]"
346
+ " in this account.\nPlease check with your administrator or try"
347
+ " checking your credentials configuration.",
348
+ )
349
+ elif error.response["Error"]["Code"] == "ExpiredToken":
350
+ logger.error("Token has expired. Please try again.")
351
+ continue
352
+ elif (
353
+ error.response["Error"]["Code"] == "ValidationError"
354
+ and "Invalid length for parameter TokenCode" in error.response["Error"]["Message"]
355
+ ):
356
+ logger.error("Invalid token length, it must be 6 digits long. Please try again.")
357
+ continue
358
+ elif "An error occurred" in error.response["Error"]["Message"]:
359
+ raise ExitError(50, f"Error assuming role: {error}")
360
+
361
+ if credentials is None:
362
+ raise ExitError(60, "Failed to get credentials after 3 attempts. Please try again later.")
363
+
364
+ # write credentials on aws/<project>/credentials (create the file if it doesn't exist first)
365
+ paths.aws_credentials_file.touch(exist_ok=True)
366
+ credentials_updater = ConfigUpdater()
367
+ credentials_updater.read(paths.aws_credentials_file)
368
+
369
+ update_config_section(
370
+ credentials_updater,
371
+ layer_profile,
372
+ data={
373
+ "aws_access_key_id": credentials["AccessKeyId"],
374
+ "aws_secret_access_key": credentials["SecretAccessKey"],
375
+ "aws_session_token": credentials["SessionToken"],
376
+ },
377
+ )
378
+ logger.info(f"Credentials written successfully.")
@@ -15,7 +15,8 @@ from leverage.modules.runner import Runner
15
15
  from leverage.modules.utils import _handle_subcommand
16
16
  from leverage._utils import get_or_create_section, ExitError
17
17
  from leverage._internals import pass_state, pass_runner, pass_paths
18
- from leverage.modules.auth import get_sso_access_token, check_sso_token, refresh_layer_credentials
18
+ from leverage.modules.auth import get_sso_access_token
19
+ from leverage.modules.auth import _perform_authentication as perform_authentication
19
20
 
20
21
 
21
22
  CONTEXT_SETTINGS = {"ignore_unknown_options": True}
@@ -24,21 +25,6 @@ CONTEXT_SETTINGS = {"ignore_unknown_options": True}
24
25
  AWS_SSO_LOGIN_URL = "{sso_url}/#/device?user_code={user_code}"
25
26
 
26
27
 
27
- @pass_paths
28
- def refresh_aws_credentials(paths: PathsHandler) -> None:
29
- """
30
- Refresh the AWS credentials for the current project.
31
- """
32
- check_sso_token(paths)
33
-
34
- try: # if we are not in a layer, we don't need to refresh the credentials
35
- paths.check_for_layer_location()
36
- except ExitError:
37
- return
38
-
39
- refresh_layer_credentials(paths)
40
-
41
-
42
28
  def get_account_roles(sso_client: Any, access_token: str) -> Dict[str, Dict[str, str]]:
43
29
  """
44
30
  Fetch the accounts and roles from the user.
@@ -116,21 +102,17 @@ def configure_sso_profiles(paths: PathsHandler) -> None:
116
102
  @click.pass_context
117
103
  def aws(context: click.Context, state: Any, args: Tuple[str, ...]) -> None:
118
104
  """Run AWS CLI commands in the context of the current project."""
119
-
120
- credentials_env_vars = {
121
- "AWS_SHARED_CREDENTIALS_FILE": str(state.paths.aws_credentials_file),
122
- "AWS_CONFIG_FILE": str(state.paths.aws_config_file),
123
- }
124
105
  state.runner = Runner(
125
106
  binary="aws",
126
107
  error_message=(
127
108
  f"AWS CLI not found on system. "
128
109
  f"Please install it following the instructions at: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
129
110
  ),
130
- env_vars=credentials_env_vars,
111
+ env_vars=state.environment,
131
112
  )
132
113
 
133
- _handle_subcommand(context=context, runner=state.runner, args=args, pre_invocation_callback=refresh_aws_credentials)
114
+ authenticate = pass_paths(lambda paths: perform_authentication(paths))
115
+ _handle_subcommand(context=context, runner=state.runner, args=args, pre_invocation_callback=authenticate)
134
116
 
135
117
 
136
118
  @aws.group(invoke_without_command=True, add_help_option=False, context_settings=CONTEXT_SETTINGS)
@@ -2,9 +2,10 @@
2
2
  Credentials managing module.
3
3
  """
4
4
 
5
+ import re
5
6
  import csv
6
7
  import json
7
- import re
8
+ import shutil
8
9
  from pathlib import Path
9
10
  from functools import wraps
10
11
  from typing import Optional, Union
@@ -287,17 +288,13 @@ def credentials(state):
287
288
  else:
288
289
  logger.info("Reading info from build.env")
289
290
 
290
- credentials_env_vars = {
291
- "AWS_SHARED_CREDENTIALS_FILE": str(state.paths.aws_credentials_file),
292
- "AWS_CONFIG_FILE": str(state.paths.aws_config_file),
293
- }
294
291
  state.runner = Runner(
295
292
  binary="aws",
296
293
  error_message=(
297
294
  f"AWS CLI not found on system. "
298
295
  f"Please install it following the instructions at: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
299
296
  ),
300
- env_vars=credentials_env_vars,
297
+ env_vars=state.environment,
301
298
  )
302
299
 
303
300
 
@@ -375,7 +372,7 @@ def _profile_is_configured(awscli: Runner, profile: str):
375
372
  Returns:
376
373
  bool: Whether the profile was already configured or not.
377
374
  """
378
- exit_code, _ = awscli.exec("configure", "list", "--profile", profile)
375
+ exit_code, _, _ = awscli.exec("configure", "list", "--profile", profile)
379
376
 
380
377
  return not exit_code
381
378
 
@@ -444,12 +441,12 @@ def configure_credentials(
444
441
 
445
442
  if make_backup:
446
443
  logger.info("Backing up credentials file.")
447
- Path(paths.aws_credentials_file).copy(Path(paths.aws_credentials_file).with_suffix(".bkp"))
444
+ shutil.copy(paths.aws_credentials_file, paths.aws_credentials_file.with_suffix(".bkp"))
448
445
 
449
446
  values = {"aws_access_key_id": key_id, "aws_secret_access_key": secret_key}
450
447
 
451
448
  for key, value in values.items():
452
- exit_code, output = awscli.exec("configure", "set", key, value, "--profile", profile)
449
+ exit_code, output, _ = awscli.exec("configure", "set", key, value, "--profile", profile)
453
450
  if exit_code:
454
451
  raise ExitError(exit_code, f"AWS CLI error: {output}")
455
452
 
@@ -470,7 +467,7 @@ def _credentials_are_valid(awscli: Runner, profile: str):
470
467
  Returns:
471
468
  bool: Whether the credentials are valid.
472
469
  """
473
- error_code, output = awscli.exec("sts", "get-caller-identity", "--profile", profile)
470
+ error_code, output, _ = awscli.exec("sts", "get-caller-identity", "--profile", profile)
474
471
 
475
472
  return error_code != 255 and "InvalidClientTokenId" not in output
476
473
 
@@ -485,7 +482,7 @@ def _get_management_account_id(awscli: Runner, profile: str):
485
482
  Returns:
486
483
  str: Management account id.
487
484
  """
488
- exit_code, caller_identity = awscli.exec("sts", "get-caller-identity", "--output", "json", "--profile", profile)
485
+ exit_code, caller_identity, _ = awscli.exec("sts", "get-caller-identity", "--output", "json", "--profile", profile)
489
486
  if exit_code:
490
487
  raise ExitError(exit_code, f"AWS CLI error: {caller_identity}")
491
488
 
@@ -504,7 +501,7 @@ def _get_organization_accounts(awscli: Runner, profile: str, project_name: str):
504
501
  Returns:
505
502
  dict: Mapping of organization accounts names to ids.
506
503
  """
507
- exit_code, organization_accounts = awscli.exec(
504
+ exit_code, organization_accounts, _ = awscli.exec(
508
505
  "organizations", "list-accounts", "--output", "json", "--profile", profile
509
506
  )
510
507
 
@@ -533,7 +530,7 @@ def _get_mfa_serial(awscli: Runner, profile: str):
533
530
  Returns:
534
531
  str: MFA device serial.
535
532
  """
536
- exit_code, mfa_devices = awscli.exec("iam", "list-mfa-devices", "--output", "json", "--profile", profile)
533
+ exit_code, mfa_devices, _ = awscli.exec("iam", "list-mfa-devices", "--output", "json", "--profile", profile)
537
534
  if exit_code:
538
535
  raise ExitError(exit_code, f"AWS CLI error: {mfa_devices}")
539
536
  mfa_devices = json.loads(mfa_devices)
@@ -561,7 +558,7 @@ def configure_profile(awscli: Runner, profile: str, values: dict):
561
558
  """
562
559
  logger.info(f"\tConfiguring profile [bold]{profile}[/bold]")
563
560
  for key, value in values.items():
564
- exit_code, output = awscli.exec("configure", "set", key, value, "--profile", profile)
561
+ exit_code, output, _ = awscli.exec("configure", "set", key, value, "--profile", profile)
565
562
  if exit_code:
566
563
  raise ExitError(exit_code, f"AWS CLI error: {output}")
567
564
 
@@ -614,10 +611,10 @@ def configure_accounts_profiles(
614
611
  if mfa_serial:
615
612
  account_profile["mfa_serial"] = mfa_serial
616
613
  # A profile identifier looks like `le-security-oaar`
617
- account_profiles[f"{short_name}-{account_name}-{PROFILES[_type]['profile_role']}"] = account_profile
614
+ account_profiles[f"{short_name}-{account_name}-{PROFILES[_type]['profile_role']}-mfa"] = account_profile
618
615
 
619
616
  logger.info("Backing up account profiles file.")
620
- Path(paths.aws_config_file).copy(Path(paths.aws_config_file).with_suffix(".bkp"))
617
+ shutil.copy(paths.aws_config_file, paths.aws_config_file.with_suffix(".bkp"))
621
618
 
622
619
  for profile_identifier, profile_values in account_profiles.items():
623
620
  configure_profile(profile_identifier, profile_values)
@@ -14,8 +14,8 @@ from leverage.modules.aws import aws
14
14
  from leverage.modules.runner import Runner
15
15
  from leverage.modules.tfrunner import TFRunner
16
16
  from leverage.modules.utils import _handle_subcommand
17
- from leverage.modules.auth import check_sso_token, refresh_layer_credentials
18
- from leverage._internals import pass_state, pass_paths
17
+ from leverage.modules.auth import _perform_authentication as perform_authentication
18
+ from leverage._internals import pass_state, pass_paths, pass_environment
19
19
 
20
20
 
21
21
  @dataclass
@@ -29,26 +29,9 @@ class MetadataTypes(Enum):
29
29
  K8S_CLUSTER = "k8s-eks-cluster"
30
30
 
31
31
 
32
- CONTEXT_SETTINGS = {"ignore_unknown_options": True}
33
-
34
32
  METADATA_FILENAME = "metadata.yaml"
35
33
 
36
34
 
37
- @pass_paths
38
- def refresh_kubectl_credentials(paths: PathsHandler) -> None:
39
- """
40
- Refresh the AWS credentials for the current project to be used by kubectl.
41
- """
42
- check_sso_token(paths)
43
-
44
- try: # if we are not in a layer, we don't need to refresh the credentials
45
- paths.check_for_layer_location()
46
- except ExitError:
47
- return
48
-
49
- refresh_layer_credentials(paths)
50
-
51
-
52
35
  @click.group(invoke_without_command=True, context_settings={"ignore_unknown_options": True})
53
36
  @click.argument("args", nargs=-1, type=click.UNPROCESSED)
54
37
  @pass_state
@@ -58,26 +41,22 @@ def kubectl(context, state, args):
58
41
 
59
42
  kubeconfig_dir = state.paths.home / ".kube" / state.paths.project
60
43
  kubeconfig_dir.mkdir(parents=True, exist_ok=True)
44
+ state.environment["KUBECONFIG"] = str(kubeconfig_dir / "config")
61
45
 
62
- credentials_env_vars = {
63
- "AWS_SHARED_CREDENTIALS_FILE": str(state.paths.aws_credentials_file),
64
- "AWS_CONFIG_FILE": str(state.paths.aws_config_file),
65
- "KUBECONFIG": str(kubeconfig_dir / "config"),
66
- }
67
46
  state.runner = Runner(
68
47
  binary="kubectl",
69
48
  error_message=(
70
49
  f"Kubectl not found on system. "
71
50
  f"Please install it following the instructions at: https://kubernetes.io/docs/tasks/tools/#kubectl"
72
51
  ),
73
- env_vars=credentials_env_vars,
74
- )
75
- _handle_subcommand(
76
- context=context, runner=state.runner, args=args, pre_invocation_callback=refresh_kubectl_credentials
52
+ env_vars=state.environment,
77
53
  )
78
54
 
55
+ authenticate = pass_paths(lambda paths: perform_authentication(paths))
56
+ _handle_subcommand(context=context, runner=state.runner, args=args, pre_invocation_callback=authenticate)
57
+
79
58
 
80
- def _configure(ci: ClusterInfo = None, layer_path: Path = None):
59
+ def _configure(environment: dict, ci: ClusterInfo = None, layer_path: Path = None):
81
60
  """
82
61
  Add the given EKS cluster configuration to the .kube/ files.
83
62
  """
@@ -87,33 +66,31 @@ def _configure(ci: ClusterInfo = None, layer_path: Path = None):
87
66
  else:
88
67
  # otherwise go get them from the layer
89
68
  logger.info("Retrieving k8s cluster information...")
90
- cmd = _get_eks_kube_config(layer_path).split(" ")[1:]
69
+ cmd = _get_eks_kube_config(environment, layer_path).split(" ")[1:]
91
70
 
92
71
  logger.info("Configuring context...")
93
72
  try:
94
- click.get_current_context().invoke(aws, args=cmd)
73
+ exit_code, _, _ = Runner(binary="aws", env_vars=environment).exec(*cmd)
95
74
  except ExitError as e:
96
- raise ExitError(e.exit_code, f"Failed to configure kubectl context: {e.message}")
75
+ raise ExitError(e.exit_code, f"Could not locate AWS cli binary.")
76
+ if exit_code:
77
+ raise ExitError(exit_code, f"Failed to configure kubectl context: {exit_code}")
97
78
 
98
79
  logger.info("Done.")
99
80
 
100
81
 
101
82
  @pass_paths
102
- def _get_eks_kube_config(paths: PathsHandler, layer_path: Path) -> str:
83
+ def _get_eks_kube_config(paths: PathsHandler, environment: dict, layer_path: Path) -> str:
103
84
  # TODO: Get rid of this ugly workaround
104
- credentials_env_vars = {
105
- "AWS_SHARED_CREDENTIALS_FILE": str(paths.aws_credentials_file),
106
- "AWS_CONFIG_FILE": str(paths.aws_config_file),
107
- }
108
85
  try:
109
- tfrunner = TFRunner(binary=paths.tf_binary, env_vars=credentials_env_vars)
86
+ tfrunner = TFRunner(binary=paths.tf_binary, env_vars=environment)
110
87
  except ExitError as e:
111
88
  try:
112
- tfrunner = TFRunner(binary=paths.tf_binary, terraform=True, env_vars=credentials_env_vars)
89
+ tfrunner = TFRunner(binary=paths.tf_binary, terraform=True, env_vars=environment)
113
90
  except ExitError:
114
- raise ExitError(1, "Could not locate TF binary.")
91
+ raise ExitError(e.exit_code, f"Could not locate TF binary.")
115
92
 
116
- refresh_kubectl_credentials()
93
+ perform_authentication(paths)
117
94
  exit_code, output, error = tfrunner.exec("output", "-no-color", working_dir=layer_path)
118
95
  if exit_code:
119
96
  raise ExitError(exit_code, f"Failed to get EKS kube config: {error}")
@@ -126,18 +103,20 @@ def _get_eks_kube_config(paths: PathsHandler, layer_path: Path) -> str:
126
103
  return aws_eks_cmd + f" --region {region}"
127
104
 
128
105
 
129
- @kubectl.command(context_settings=CONTEXT_SETTINGS)
106
+ @kubectl.command()
130
107
  @pass_paths
131
- def configure(paths: PathsHandler):
108
+ @pass_environment
109
+ def configure(environment: dict, paths: PathsHandler):
132
110
  """Automatically add the EKS cluster from the layer into your kubectl config file."""
133
- _configure(layer_path=paths.cwd)
111
+ paths.check_for_cluster_layer()
112
+ _configure(environment, layer_path=paths.cwd)
134
113
 
135
114
 
136
- def _scan_clusters(paths: PathsHandler):
115
+ def _scan_clusters(cwd: Path):
137
116
  """
138
117
  Scan all the subdirectories in search of "cluster" metadata files.
139
118
  """
140
- for root, dirs, files in os.walk(paths.paths.cwd):
119
+ for root, dirs, files in os.walk(cwd):
141
120
  # exclude hidden directories
142
121
  dirs[:] = [d for d in dirs if d[0] != "."]
143
122
 
@@ -158,14 +137,15 @@ def _scan_clusters(paths: PathsHandler):
158
137
  yield Path(root), data
159
138
 
160
139
 
161
- @kubectl.command(context_settings=CONTEXT_SETTINGS)
140
+ @kubectl.command()
162
141
  @pass_paths
163
- def discover(paths: PathsHandler):
142
+ @pass_environment
143
+ def discover(environment: dict, paths: PathsHandler):
164
144
  """
165
145
  Do a scan down the tree of subdirectories looking for k8s clusters metadata files.
166
146
  Open up a menu with all the found items, where you can pick up and configure it on your .kubeconfig file.
167
147
  """
168
- cluster_files = [(path, data) for path, data in _scan_clusters(paths)]
148
+ cluster_files = [(path, data) for path, data in _scan_clusters(paths.cwd)]
169
149
  if not cluster_files:
170
150
  raise ExitError(1, "No clusters found.")
171
151
 
@@ -185,4 +165,4 @@ def discover(paths: PathsHandler):
185
165
  region=cluster_data["data"]["region"],
186
166
  )
187
167
 
188
- _configure(cluster_info, layer_path)
168
+ _configure(environment, cluster_info, layer_path)
@@ -64,6 +64,7 @@ class Runner:
64
64
  env_vars: Optional[Dict[str, str]] = None,
65
65
  working_dir: Optional[Path] = None,
66
66
  interactive: bool = True,
67
+ raises: bool = False,
67
68
  ) -> Union[int, Tuple[int, str, str]]:
68
69
  """
69
70
  Execute command with the binary.
@@ -73,6 +74,7 @@ class Runner:
73
74
  env_vars: Environment variables to set during execution (overrides instance env_vars)
74
75
  working_dir: Working directory for command execution
75
76
  interactive: If True, run interactively. If False, capture output
77
+ raises: If True, raise an ExitError if the command fails
76
78
 
77
79
  Returns:
78
80
  If interactive=True: Exit code (int)
@@ -93,17 +95,23 @@ class Runner:
93
95
  logger.debug(f"Working directory: {working_dir or Path.cwd()}")
94
96
  logger.debug(f"Additional environment variables: {merged_env_vars}")
95
97
 
96
- if interactive:
97
- # Interactive execution
98
- process = subprocess.run(command, env=env, cwd=working_dir)
99
- return process.returncode
100
- else:
101
- # Silent execution with output capture
102
- process = subprocess.run(command, env=env, cwd=working_dir, capture_output=True, text=True)
103
- return process.returncode, process.stdout.strip(), process.stderr.strip()
98
+ process = subprocess.run(
99
+ command, env=env, cwd=working_dir, capture_output=not interactive, text=not interactive
100
+ )
101
+
102
+ if raises and not interactive and process.returncode:
103
+ raise ExitError(process.returncode, f"Command execution failed: {process.stderr.strip()}")
104
+
105
+ return (
106
+ process.returncode if interactive else (process.returncode, process.stdout.strip(), process.stderr.strip())
107
+ )
104
108
 
105
109
  def exec(
106
- self, *args: str, env_vars: Optional[Dict[str, str]] = None, working_dir: Optional[Path] = None
110
+ self,
111
+ *args: str,
112
+ env_vars: Optional[Dict[str, str]] = None,
113
+ working_dir: Optional[Path] = None,
114
+ raises: bool = True,
107
115
  ) -> Tuple[int, str, str]:
108
116
  """
109
117
  Execute command with the binary in non-interactive mode (captures output).
@@ -114,11 +122,12 @@ class Runner:
114
122
  *args: Command arguments to pass to the binary
115
123
  env_vars: Environment variables to set during execution (overrides instance env_vars)
116
124
  working_dir: Working directory for command execution
125
+ raises: If True, raise an ExitError if the command fails. If False, return the exit code.
117
126
 
118
127
  Returns:
119
128
  Tuple of (exit_code, stdout, stderr)
120
129
  """
121
- return self.run(*args, env_vars=env_vars, working_dir=working_dir, interactive=False)
130
+ return self.run(*args, env_vars=env_vars, working_dir=working_dir, interactive=False, raises=raises)
122
131
 
123
132
  def __repr__(self):
124
133
  return f"Runner(binary_input='{self.binary_input}', binary_path='{self.binary_path}')"
@@ -11,7 +11,7 @@ from leverage.modules.tfrunner import TFRunner
11
11
  from leverage._utils import ExitError, parse_tf_file
12
12
  from leverage._internals import pass_paths, pass_runner, pass_state
13
13
  from leverage._backend_config import get_backend_key, set_backend_key
14
- from leverage.modules.auth import refresh_layer_credentials, check_sso_token
14
+ from leverage.modules.auth import authenticate
15
15
 
16
16
  REGION = r"(global|([a-z]{2}(-gov)?)-(central|(north|south)?(east|west)?)-\d)"
17
17
 
@@ -28,11 +28,7 @@ def tofu(state):
28
28
  counterparts in the container. For example as in `leverage tofu apply -auto-approve` or
29
29
  `leverage tofu init -reconfigure`
30
30
  """
31
- credentials_env_vars = {
32
- "AWS_SHARED_CREDENTIALS_FILE": str(state.paths.aws_credentials_file),
33
- "AWS_CONFIG_FILE": str(state.paths.aws_config_file),
34
- }
35
- state.runner = TFRunner(binary=state.paths.tf_binary, env_vars=credentials_env_vars)
31
+ state.runner = TFRunner(binary=state.paths.tf_binary, env_vars=state.environment)
36
32
 
37
33
 
38
34
  @click.group()
@@ -44,11 +40,7 @@ def terraform(state):
44
40
  counterparts in the container. For example as in `leverage terraform apply -auto-approve` or
45
41
  `leverage terraform init -reconfigure`
46
42
  """
47
- credentials_env_vars = {
48
- "AWS_SHARED_CREDENTIALS_FILE": str(state.paths.aws_credentials_file),
49
- "AWS_CONFIG_FILE": str(state.paths.aws_config_file),
50
- }
51
- state.runner = TFRunner(binary=state.paths.tf_binary, terraform=True, env_vars=credentials_env_vars)
43
+ state.runner = TFRunner(binary=state.paths.tf_binary, terraform=True, env_vars=state.environment)
52
44
 
53
45
 
54
46
  CONTEXT_SETTINGS = {"ignore_unknown_options": True}
@@ -170,25 +162,21 @@ def _format(tf, args):
170
162
 
171
163
  @click.command("force-unlock")
172
164
  @click.argument("lock_id", metavar="LOCK_ID")
165
+ @authenticate
173
166
  @pass_paths
174
167
  @pass_runner
175
168
  def force_unlock(tf, paths: PathsHandler, lock_id):
176
169
  """Force unlock the state file."""
177
- check_sso_token(paths)
178
- refresh_layer_credentials(paths)
179
- if exit_code := tf.run("force-unlock", lock_id):
180
- raise Exit(exit_code)
170
+ tf.run("force-unlock", lock_id)
181
171
 
182
172
 
183
173
  @click.command()
174
+ @authenticate
184
175
  @pass_paths
185
176
  @pass_runner
186
177
  def validate(tf, paths: PathsHandler):
187
178
  """Validate code of the current directory. Previous initialization might be needed."""
188
- check_sso_token(paths)
189
- refresh_layer_credentials(paths)
190
- if exit_code := tf.run("validate", *tf_default_args()):
191
- raise Exit(exit_code)
179
+ tf.run("validate", *tf_default_args())
192
180
 
193
181
 
194
182
  @click.command("validate-layout")
@@ -204,17 +192,15 @@ def validate_layout(paths):
204
192
  @pass_runner
205
193
  def _import(tf, address, _id):
206
194
  """Import a resource."""
207
- if exit_code := tf.run("import", *tf_default_args(), address, _id):
208
- raise Exit(exit_code)
195
+ tf.run("import", *tf_default_args(), address, _id)
209
196
 
210
197
 
211
198
  @click.command("refresh-credentials")
199
+ @authenticate
212
200
  @pass_paths
213
201
  def refresh_credentials(paths):
214
202
  """Refresh the AWS credentials used on the current layer."""
215
203
  paths.check_for_layer_location()
216
- check_sso_token(paths)
217
- refresh_layer_credentials(paths)
218
204
 
219
205
 
220
206
  # ###########################################################################
@@ -323,6 +309,7 @@ def validate_for_all_commands(layer, skip_validation=False):
323
309
  # ###########################################################################
324
310
  # BASE COMMAND EXECUTORS
325
311
  # ###########################################################################
312
+ @authenticate
326
313
  @pass_paths
327
314
  @pass_runner
328
315
  def _init(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir: Path):
@@ -335,22 +322,15 @@ def _init(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir: P
335
322
  )
336
323
  init_args = (*filtered_args, f"-backend-config={paths.backend_tfvars}")
337
324
 
338
- check_sso_token(paths)
339
- refresh_layer_credentials(paths)
340
-
341
- if exit_code := tf.run("init", *init_args, working_dir=working_dir):
342
- raise Exit(exit_code)
325
+ tf.run("init", *tf_default_args(), *init_args, working_dir=working_dir)
343
326
 
344
327
 
328
+ @authenticate
345
329
  @pass_paths
346
330
  @pass_runner
347
331
  def _plan(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir: Path):
348
332
  """Generate an execution plan for this layer."""
349
- check_sso_token(paths)
350
- refresh_layer_credentials(paths)
351
-
352
- if exit_code := tf.run("plan", *tf_default_args(), *args, working_dir=working_dir):
353
- raise Exit(exit_code)
333
+ tf.run("plan", *tf_default_args(), *args, working_dir=working_dir)
354
334
 
355
335
 
356
336
  def has_a_plan_file(args: Sequence[str]) -> bool:
@@ -407,6 +387,7 @@ def has_a_plan_file(args: Sequence[str]) -> bool:
407
387
  return True
408
388
 
409
389
 
390
+ @authenticate
410
391
  @pass_paths
411
392
  @pass_runner
412
393
  def _apply(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir: Path):
@@ -414,33 +395,23 @@ def _apply(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir:
414
395
  default_args = () if has_a_plan_file(args) else tf_default_args()
415
396
  logger.debug(f"Default args passed to apply command: {default_args}")
416
397
 
417
- check_sso_token(paths)
418
- refresh_layer_credentials(paths)
419
-
420
- if exit_code := tf.run("apply", *default_args, *args, working_dir=working_dir):
421
- raise Exit(exit_code)
398
+ tf.run("apply", *default_args, *args, working_dir=working_dir)
422
399
 
423
400
 
401
+ @authenticate
424
402
  @pass_paths
425
403
  @pass_runner
426
404
  def _output(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir: Path):
427
405
  """Show all output variables of this layer."""
428
- check_sso_token(paths)
429
- refresh_layer_credentials(paths)
430
-
431
- if exit_code := tf.run("output", *args, working_dir=working_dir):
432
- raise Exit(exit_code)
406
+ tf.run("output", *args, working_dir=working_dir)
433
407
 
434
408
 
409
+ @authenticate
435
410
  @pass_paths
436
411
  @pass_runner
437
412
  def _destroy(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir: Path):
438
413
  """Destroy infrastructure in this layer."""
439
- check_sso_token(paths)
440
- refresh_layer_credentials(paths)
441
-
442
- if exit_code := tf.run("destroy", *tf_default_args(), *args, working_dir=working_dir):
443
- raise Exit(exit_code)
414
+ tf.run("destroy", *tf_default_args(), *args, working_dir=working_dir)
444
415
 
445
416
 
446
417
  # ###########################################################################
@@ -4,28 +4,26 @@ from click.exceptions import Exit
4
4
  from leverage._internals import pass_state
5
5
  from leverage.modules.runner import Runner
6
6
  from leverage.modules.tf import tf_default_args
7
- from leverage.modules.auth import check_sso_token, refresh_layer_credentials
7
+ from leverage.modules.auth import authenticate
8
8
 
9
9
 
10
10
  @click.command()
11
11
  @click.argument("args", nargs=-1)
12
+ @authenticate
12
13
  @pass_state
13
14
  def tfautomv(state, args):
14
15
  """Run TFAutomv commands in the context of the current project.`"""
15
16
  tf_default_args_string = " ".join(tf_default_args())
16
- credentials_env_vars = {
17
- "AWS_SHARED_CREDENTIALS_FILE": str(state.paths.aws_credentials_file),
18
- "AWS_CONFIG_FILE": str(state.paths.aws_config_file),
19
- "TF_CLI_ARGS_init": tf_default_args_string,
20
- "TF_CLI_ARGS_plan": tf_default_args_string,
21
- }
17
+ state.environment["TF_CLI_ARGS_init"] = tf_default_args_string
18
+ state.environment["TF_CLI_ARGS_plan"] = tf_default_args_string
19
+
22
20
  state.runner = Runner(
23
21
  binary="tfautomv",
24
22
  error_message=(
25
23
  f"TFAutomv not found on system. "
26
24
  f"Please install it following the instructions at: https://github.com/busser/tfautomv?tab=readme-ov-file#installation"
27
25
  ),
28
- env_vars=credentials_env_vars,
26
+ env_vars=state.environment,
29
27
  )
30
28
 
31
29
  tf_binary = "tofu" if not state.paths.tf_binary else state.paths.tf_binary
@@ -36,8 +34,5 @@ def tfautomv(state, args):
36
34
  )
37
35
  tfautomv_args = (*filtered_args, f"--terraform-bin={tf_binary}")
38
36
 
39
- check_sso_token(state.paths)
40
- refresh_layer_credentials(state.paths)
41
-
42
37
  if exit_code := state.runner.run(*tfautomv_args):
43
38
  raise Exit(exit_code)
@@ -2,6 +2,8 @@ import subprocess
2
2
  from pathlib import Path
3
3
  from typing import Dict, Optional
4
4
 
5
+ from click.exceptions import Exit
6
+
5
7
  from leverage._utils import ExitError
6
8
  from leverage.modules.runner import Runner
7
9
 
@@ -52,7 +54,7 @@ class TFRunner(Runner):
52
54
  *args: str,
53
55
  env_vars: Optional[Dict[str, str]] = None,
54
56
  working_dir: Optional[Path] = None,
55
- interactive: bool = True,
57
+ raises: bool = True,
56
58
  ):
57
59
  """
58
60
  Run the Terraform/OpenTofu binary with the given arguments.
@@ -61,15 +63,20 @@ class TFRunner(Runner):
61
63
  *args: Command and arguments to pass (e.g., 'plan', '-out=plan.tfplan')
62
64
  env_vars: Environment variables for this specific execution
63
65
  working_dir: Working directory for command execution
64
- interactive: If True, run interactively. If False, capture output
66
+ raises: If True, raise an ExitError if the command fails. If False, return the exit code.
65
67
 
66
68
  Returns:
67
- If interactive=True: Exit code (int)
68
- If interactive=False: Tuple of (exit_code, stdout, stderr)
69
+ Exit code (int)
69
70
  """
70
- return super().run(*args, env_vars=env_vars, working_dir=working_dir, interactive=interactive)
71
+ return super().run(*args, env_vars=env_vars, working_dir=working_dir, raises=raises)
71
72
 
72
- def exec(self, *args: str, env_vars: Optional[Dict[str, str]] = None, working_dir: Optional[Path] = None):
73
+ def exec(
74
+ self,
75
+ *args: str,
76
+ env_vars: Optional[Dict[str, str]] = None,
77
+ working_dir: Optional[Path] = None,
78
+ raises: bool = False,
79
+ ):
73
80
  """
74
81
  Execute the Terraform/OpenTofu binary in non-interactive mode (captures output).
75
82
 
@@ -79,8 +86,9 @@ class TFRunner(Runner):
79
86
  *args: Command and arguments to pass (e.g., 'plan', '-out=plan.tfplan')
80
87
  env_vars: Environment variables for this specific execution
81
88
  working_dir: Working directory for command execution
89
+ raises: If True, raise an ExitError if the command fails. If False, return the exit code.
82
90
 
83
91
  Returns:
84
92
  Tuple of (exit_code, stdout, stderr)
85
93
  """
86
- return self.run(*args, env_vars=env_vars, working_dir=working_dir, interactive=False)
94
+ return super().run(*args, env_vars=env_vars, working_dir=working_dir, interactive=False, raises=raises)
@@ -34,13 +34,16 @@ def _handle_subcommand(
34
34
  # Run the command directly
35
35
  if pre_invocation_callback:
36
36
  pre_invocation_callback()
37
- if exit_code := runner.run(*args):
38
- raise Exit(exit_code)
39
-
37
+ exit_code = runner.run(*args)
38
+ raise Exit(exit_code)
39
+
40
+ subcommand = context.command.commands.get(subcommand)
41
+ # Check that the subcommand arguments are valid
42
+ subcommand.make_context(
43
+ info_name=subcommand.name, args=list(args)[args.index(subcommand.name) + 1 :], parent=context
44
+ )
45
+ # Invoke wrapped command
46
+ if not subcommand.params:
47
+ context.invoke(subcommand)
40
48
  else:
41
- # Invoke wrapped command
42
- subcommand = context.command.commands.get(subcommand)
43
- if not subcommand.params:
44
- context.invoke(subcommand)
45
- else:
46
- context.forward(subcommand)
49
+ context.forward(subcommand)
@@ -129,7 +129,7 @@ def get_build_script_path(filename="build.py"):
129
129
  cur_path = cur_path.parent
130
130
 
131
131
 
132
- class PathsHandler:
132
+ class PathsHandler: # TODO: Turn this class into a something that represents a leverage project
133
133
  COMMON_TF_VARS = "common.tfvars"
134
134
  ACCOUNT_TF_VARS = "account.tfvars"
135
135
  BACKEND_TF_VARS = "backend.tfvars"
@@ -153,6 +153,12 @@ class PathsHandler:
153
153
  account_config = self.account_config_dir / self.ACCOUNT_TF_VARS
154
154
  self.account_conf = hcl2.loads(account_config.read_text()) if account_config.exists() else {}
155
155
 
156
+ backend_config = self.account_config_dir / self.BACKEND_TF_VARS
157
+ self.backend_conf = hcl2.loads(backend_config.read_text()) if backend_config.exists() else {}
158
+
159
+ # Get MFA enabled status
160
+ self.mfa_enabled = env_conf.get("MFA_ENABLED", "false")
161
+
156
162
  # Get project name
157
163
  self.project = self.common_conf.get("project", env_conf.get("PROJECT", False))
158
164
  if not self.project:
@@ -165,23 +171,15 @@ class PathsHandler:
165
171
  self.aws_credentials_dir.mkdir(parents=True)
166
172
  self.sso_cache = self.aws_credentials_dir / "sso" / "cache"
167
173
 
168
- # If not empty make the binary path absolute
169
- if tf_binary := env_conf.get("TF_BINARY", ""):
170
- binary_path = Path(tf_binary)
171
- if tf_binary.startswith("~"):
172
- self.tf_binary = str(binary_path.expanduser())
173
- elif not binary_path.is_absolute() and len(binary_path.parts) > 1:
174
- self.tf_binary = str((self.root_dir / tf_binary).resolve())
175
- else:
176
- self.tf_binary = tf_binary
177
-
178
- def update_cwd(self, new_cwd):
179
- self.cwd = new_cwd
180
- acc_folder = new_cwd.relative_to(self.root_dir).parts[0]
181
-
182
- self.account_config_dir = self.root_dir / acc_folder / "config"
183
- account_config_path = self.account_config_dir / self.ACCOUNT_TF_VARS
184
- self.account_conf = hcl2.loads(account_config_path.read_text())
174
+ # Make the binary path absolute
175
+ tf_binary = env_conf.get("TF_BINARY", "")
176
+ binary_path = Path(tf_binary)
177
+ if tf_binary.startswith("~"):
178
+ self.tf_binary = str(binary_path.expanduser())
179
+ elif not binary_path.is_absolute() and len(binary_path.parts) > 1:
180
+ self.tf_binary = str((self.root_dir / tf_binary).resolve())
181
+ else:
182
+ self.tf_binary = tf_binary
185
183
 
186
184
  @property
187
185
  def common_tfvars(self):
@@ -203,6 +201,10 @@ class PathsHandler:
203
201
  def aws_credentials_file(self):
204
202
  return self.aws_credentials_dir / "credentials"
205
203
 
204
+ @property
205
+ def aws_cache_dir(self):
206
+ return self.aws_credentials_dir / "cache"
207
+
206
208
  @property
207
209
  def sso_token_file(self):
208
210
  return self.sso_cache / "token"
@@ -243,7 +245,7 @@ class PathsHandler:
243
245
  if path in (self.root_dir, self.account_dir):
244
246
  raise ExitError(
245
247
  1,
246
- "This command cannot run neither in the root of the project or in" " the root directory of an account.",
248
+ "This command cannot run neither in the root of the project or in the root directory of an account.",
247
249
  )
248
250
 
249
251
  if not list(path.glob("*.tf")):
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "leverage"
3
- version = "2.2.0rc3"
3
+ version = "2.2.0rc5"
4
4
  description = "Binbash Leverage Command-Line tool."
5
5
  authors = ["BinBash Inc <leverage@binbash.com.ar>"]
6
6
  license = "MIT"
File without changes
File without changes