leverage 2.2.0rc3__tar.gz → 2.2.0rc4__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.
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/PKG-INFO +1 -1
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/__init__.py +1 -1
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/_internals.py +13 -0
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/leverage.py +4 -0
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/modules/auth.py +177 -3
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/modules/aws.py +5 -23
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/modules/credentials.py +12 -15
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/modules/kubectl.py +30 -50
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/modules/runner.py +19 -10
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/modules/tf.py +19 -48
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/modules/tfautomv.py +6 -11
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/modules/tfrunner.py +15 -7
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/modules/utils.py +12 -9
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/path.py +21 -19
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/pyproject.toml +1 -1
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/LICENSE.txt +0 -0
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/README.md +0 -0
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/_backend_config.py +0 -0
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/_parsing.py +0 -0
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/_utils.py +0 -0
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/conf.py +0 -0
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/logger.py +0 -0
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/modules/__init__.py +0 -0
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/modules/project.py +0 -0
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/modules/run.py +0 -0
- {leverage-2.2.0rc3 → leverage-2.2.0rc4}/leverage/tasks.py +0 -0
|
@@ -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
|
-
|
|
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 ("
|
|
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,129 @@ 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}"):
|
|
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
|
+
cache_file = paths.aws_cache_dir / profile_name
|
|
299
|
+
if cache_file.exists():
|
|
300
|
+
logger.debug(f"Found cached credentials in {cache_file}.")
|
|
301
|
+
cached_credentials = json.loads(cache_file.read_text())
|
|
302
|
+
|
|
303
|
+
expiration = datetime.strptime(cached_credentials.get("Expiration"), "%Y-%m-%dT%H:%M:%SZ").replace(
|
|
304
|
+
tzinfo=tzutc()
|
|
305
|
+
)
|
|
306
|
+
renewal = datetime.now(tzutc()) + timedelta(seconds=(30 * 60))
|
|
307
|
+
if renewal < expiration:
|
|
308
|
+
logger.info("Using cached credentials.")
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
else:
|
|
312
|
+
logger.debug("No cached credentials found.")
|
|
313
|
+
|
|
314
|
+
client_session = boto3.Session(botocore_session=session, profile_name=source_profile)
|
|
315
|
+
client = client_session.client("sts")
|
|
316
|
+
credentials = None
|
|
317
|
+
for _ in range(3):
|
|
318
|
+
try:
|
|
319
|
+
mfa_token_code = click.prompt("Enter MFA token code", type=str)
|
|
320
|
+
except click.exceptions.Abort:
|
|
321
|
+
raise ExitError(1, "Aborted by user.")
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
logger.debug(
|
|
325
|
+
f"Assuming role {role_arn} for {profile_name} profile with serial {mfa_serial} and token code {mfa_token_code}"
|
|
326
|
+
)
|
|
327
|
+
credentials = client.assume_role(
|
|
328
|
+
RoleArn=role_arn,
|
|
329
|
+
SourceIdentity=source_profile,
|
|
330
|
+
RoleSessionName=f"leverage-{profile_name}",
|
|
331
|
+
SerialNumber=mfa_serial,
|
|
332
|
+
TokenCode=mfa_token_code,
|
|
333
|
+
)
|
|
334
|
+
credentials = credentials["Credentials"]
|
|
335
|
+
credentials["Expiration"] = credentials["Expiration"].strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
336
|
+
cache_file.write_text(json.dumps(credentials))
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
except ClientError as error:
|
|
340
|
+
if "invalid MFA" in error.response["Error"]["Message"]:
|
|
341
|
+
logger.error("Unable to get valid credentials. Please try again.")
|
|
342
|
+
continue
|
|
343
|
+
elif error.response["Error"]["Code"] == "AccessDeniedException":
|
|
344
|
+
raise ExitError(
|
|
345
|
+
40,
|
|
346
|
+
f"User does not have permission to assume role [bold]{role_arn}[/bold]"
|
|
347
|
+
" in this account.\nPlease check with your administrator or try"
|
|
348
|
+
" checking your credentials configuration.",
|
|
349
|
+
)
|
|
350
|
+
elif error.response["Error"]["Code"] == "ExpiredToken":
|
|
351
|
+
logger.error("Token has expired. Please try again.")
|
|
352
|
+
continue
|
|
353
|
+
elif (
|
|
354
|
+
error.response["Error"]["Code"] == "ValidationError"
|
|
355
|
+
and "Invalid length for parameter TokenCode" in error.response["Error"]["Message"]
|
|
356
|
+
):
|
|
357
|
+
logger.error("Invalid token length, it must be 6 digits long. Please try again.")
|
|
358
|
+
continue
|
|
359
|
+
elif "An error occurred" in error.response["Error"]["Message"]:
|
|
360
|
+
raise ExitError(50, f"Error assuming role: {error}")
|
|
361
|
+
|
|
362
|
+
if credentials is None:
|
|
363
|
+
raise ExitError(60, "Failed to get credentials after 3 attempts. Please try again later.")
|
|
364
|
+
|
|
365
|
+
# write credentials on aws/<project>/credentials (create the file if it doesn't exist first)
|
|
366
|
+
paths.aws_credentials_file.touch(exist_ok=True)
|
|
367
|
+
credentials_updater = ConfigUpdater()
|
|
368
|
+
credentials_updater.read(paths.aws_credentials_file)
|
|
369
|
+
|
|
370
|
+
update_config_section(
|
|
371
|
+
credentials_updater,
|
|
372
|
+
layer_profile,
|
|
373
|
+
data={
|
|
374
|
+
"aws_access_key_id": credentials["AccessKeyId"],
|
|
375
|
+
"aws_secret_access_key": credentials["SecretAccessKey"],
|
|
376
|
+
"aws_session_token": credentials["SessionToken"],
|
|
377
|
+
},
|
|
378
|
+
)
|
|
379
|
+
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
|
|
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=
|
|
111
|
+
env_vars=state.environment,
|
|
131
112
|
)
|
|
132
113
|
|
|
133
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
|
|
@@ -617,7 +614,7 @@ def configure_accounts_profiles(
|
|
|
617
614
|
account_profiles[f"{short_name}-{account_name}-{PROFILES[_type]['profile_role']}"] = account_profile
|
|
618
615
|
|
|
619
616
|
logger.info("Backing up account profiles file.")
|
|
620
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
73
|
+
exit_code, _, _ = Runner(binary="aws", env_vars=environment).exec(*cmd)
|
|
95
74
|
except ExitError as e:
|
|
96
|
-
raise ExitError(e.exit_code, f"
|
|
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=
|
|
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=
|
|
89
|
+
tfrunner = TFRunner(binary=paths.tf_binary, terraform=True, env_vars=environment)
|
|
113
90
|
except ExitError:
|
|
114
|
-
raise ExitError(
|
|
91
|
+
raise ExitError(e.exit_code, f"Could not locate TF binary.")
|
|
115
92
|
|
|
116
|
-
|
|
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(
|
|
106
|
+
@kubectl.command()
|
|
130
107
|
@pass_paths
|
|
131
|
-
|
|
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
|
-
|
|
111
|
+
paths.check_for_cluster_layer()
|
|
112
|
+
_configure(environment, layer_path=paths.cwd)
|
|
134
113
|
|
|
135
114
|
|
|
136
|
-
def _scan_clusters(
|
|
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(
|
|
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(
|
|
140
|
+
@kubectl.command()
|
|
162
141
|
@pass_paths
|
|
163
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", *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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
66
|
+
raises: If True, raise an ExitError if the command fails. If False, return the exit code.
|
|
65
67
|
|
|
66
68
|
Returns:
|
|
67
|
-
|
|
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,
|
|
71
|
+
return super().run(*args, env_vars=env_vars, working_dir=working_dir, raises=raises)
|
|
71
72
|
|
|
72
|
-
def exec(
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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")):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|