leverage 3.0.1rc0__tar.gz → 3.1.0__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-3.0.1rc0 → leverage-3.1.0}/PKG-INFO +1 -1
  2. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/__init__.py +1 -1
  3. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/modules/auth.py +238 -55
  4. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/modules/aws.py +24 -2
  5. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/modules/utils.py +18 -9
  6. {leverage-3.0.1rc0 → leverage-3.1.0}/pyproject.toml +1 -1
  7. {leverage-3.0.1rc0 → leverage-3.1.0}/LICENSE.txt +0 -0
  8. {leverage-3.0.1rc0 → leverage-3.1.0}/README.md +0 -0
  9. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/_backend_config.py +0 -0
  10. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/_internals.py +0 -0
  11. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/_parsing.py +0 -0
  12. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/_utils.py +0 -0
  13. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/conf.py +0 -0
  14. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/leverage.py +0 -0
  15. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/logger.py +0 -0
  16. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/modules/__init__.py +0 -0
  17. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/modules/credentials.py +0 -0
  18. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/modules/kubectl.py +0 -0
  19. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/modules/project.py +0 -0
  20. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/modules/run.py +0 -0
  21. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/modules/runner.py +0 -0
  22. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/modules/tf.py +0 -0
  23. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/modules/tfautomv.py +0 -0
  24. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/modules/tfrunner.py +0 -0
  25. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/path.py +0 -0
  26. {leverage-3.0.1rc0 → leverage-3.1.0}/leverage/tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: leverage
3
- Version: 3.0.1rc0
3
+ Version: 3.1.0
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__ = "3.0.1rc0"
7
+ __version__ = "3.1.0"
8
8
 
9
9
  import sys
10
10
  from shutil import which
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  from datetime import datetime, timedelta
5
5
  from functools import wraps
6
6
  from configparser import NoSectionError, NoOptionError
7
+ from typing import Tuple
7
8
 
8
9
  import boto3
9
10
  import click
@@ -57,6 +58,23 @@ def get_layer_profile(raw_profile: str, config_updater: ConfigUpdater, tf_profil
57
58
  return account_id, account_name, sso_role, layer_profile
58
59
 
59
60
 
61
+ def _get_sso_profile_from_section(
62
+ config_updater: ConfigUpdater, profile_section: str, project: str
63
+ ) -> Tuple[str, str, str, str]:
64
+ """
65
+ Read account_id, account_name, sso_role and build layer_profile from an SSO config section.
66
+
67
+ Section name format: "profile <project>-sso-<account_name>".
68
+ Used by refresh_all_accounts_credentials, where iteration starts from already-known
69
+ section names rather than profile references parsed from .tf files.
70
+ """
71
+ account_name = profile_section.replace(f"profile {project}-sso-", "")
72
+ account_id = config_updater.get(profile_section, "account_id").value
73
+ sso_role = config_updater.get(profile_section, "role_name").value
74
+ layer_profile = f"{project}-{account_name}-{sso_role.lower()}"
75
+ return account_id, account_name, sso_role, layer_profile
76
+
77
+
60
78
  def update_config_section(updater: ConfigUpdater, layer_profile: str, data: dict):
61
79
  """
62
80
  Update the <layer_profile> section with the values given on <data>.
@@ -174,13 +192,16 @@ def check_sso_token(paths: PathsHandler):
174
192
  )
175
193
 
176
194
 
177
- def refresh_layer_credentials(paths: PathsHandler):
195
+ def refresh_layer_credentials(paths: PathsHandler) -> None:
178
196
  tf_profile, raw_profiles = get_profiles(paths)
179
197
  config_updater = ConfigUpdater()
180
198
  config_updater.read(paths.aws_config_file)
181
199
 
182
200
  region = config_updater.get(f"profile {paths.project}-sso", "sso_region").value
183
201
  client = boto3.client("sso", region_name=region)
202
+ access_token = _get_sso_token_or_raise(paths)
203
+ credentials_updater = None # created lazily on the first profile that needs to be written
204
+
184
205
  for raw in raw_profiles:
185
206
  try:
186
207
  account_id, account_name, sso_role, layer_profile = get_layer_profile(
@@ -192,65 +213,227 @@ def refresh_layer_credentials(paths: PathsHandler):
192
213
  except SkipProfile:
193
214
  continue
194
215
 
195
- # check if credentials need to be renewed
216
+ if _check_credentials_expiration(config_updater, layer_profile):
217
+ continue
218
+
219
+ if credentials_updater is None:
220
+ credentials_updater = _get_credentials_updater(paths)
221
+
222
+ _retrieve_and_update_credentials(
223
+ paths=paths,
224
+ client=client,
225
+ access_token=access_token,
226
+ account_id=account_id,
227
+ sso_role=sso_role,
228
+ layer_profile=layer_profile,
229
+ account_name=account_name,
230
+ config_updater=config_updater,
231
+ credentials_updater=credentials_updater,
232
+ raise_on_permission_error=True,
233
+ )
234
+
235
+
236
+ def _check_credentials_expiration(
237
+ config_updater: ConfigUpdater, layer_profile: str, force_refresh: bool = False
238
+ ) -> bool:
239
+ """
240
+ Check if credentials need to be renewed based on expiration time.
241
+
242
+ Args:
243
+ config_updater: ConfigUpdater instance with the config file loaded.
244
+ layer_profile: The layer profile name to check.
245
+ force_refresh: If True, skip expiration check and return False (needs refresh).
246
+
247
+ Returns:
248
+ bool: True if credentials are still valid and should be skipped, False if they need refresh.
249
+ """
250
+ if force_refresh:
251
+ return False
252
+
253
+ try:
254
+ expiration = int(config_updater.get(f"profile {layer_profile}", "expiration").value) / 1000
255
+ except (NoSectionError, NoOptionError):
256
+ # first time using this profile, skip into the credential's retrieval step
257
+ logger.debug("No cached credentials found.")
258
+ return False
259
+
260
+ # we reduce the validity 30 minutes, to avoid expiration over long-standing tasks
261
+ renewal = time.time() + (30 * 60)
262
+ logger.debug(f"Token expiration time: {expiration}")
263
+ logger.debug(f"Token renewal time: {renewal}")
264
+ if renewal < expiration:
265
+ # still valid, nothing to do with this profile!
266
+ logger.info("Using already configured temporary credentials.")
267
+ return True
268
+
269
+ return False
270
+
271
+
272
+ def _get_credentials_updater(paths: PathsHandler) -> ConfigUpdater:
273
+ """
274
+ Prepare and return a ConfigUpdater for the credentials file.
275
+ """
276
+ paths.aws_credentials_file.touch(exist_ok=True)
277
+ credentials_updater = ConfigUpdater()
278
+ credentials_updater.read(paths.aws_credentials_file)
279
+ return credentials_updater
280
+
281
+
282
+ def _retrieve_and_update_credentials(
283
+ paths: PathsHandler,
284
+ client,
285
+ access_token: str,
286
+ account_id: str,
287
+ sso_role: str,
288
+ layer_profile: str,
289
+ account_name: str,
290
+ config_updater: ConfigUpdater,
291
+ credentials_updater: ConfigUpdater,
292
+ raise_on_permission_error: bool = True,
293
+ ) -> bool:
294
+ """
295
+ Retrieve credentials from SSO and update both config and credentials files.
296
+
297
+ Args:
298
+ paths: PathsHandler instance.
299
+ client: boto3 SSO client.
300
+ access_token: SSO access token.
301
+ account_id: AWS account ID.
302
+ sso_role: SSO role name.
303
+ layer_profile: Layer profile name.
304
+ account_name: Account name for logging.
305
+ config_updater: ConfigUpdater instance for config file.
306
+ credentials_updater: ConfigUpdater instance for credentials file.
307
+ raise_on_permission_error: If True, raise ExitError on permission errors.
308
+ If False, log warning and return False.
309
+
310
+ Returns:
311
+ bool: True if successful, False if permission error occurred (only when raise_on_permission_error=False).
312
+
313
+ Raises:
314
+ ExitError: If raise_on_permission_error=True and permission error occurs, or for any other ClientError.
315
+ """
316
+ logger.debug(f"Retrieving role credentials for {sso_role}...")
317
+ try:
318
+ credentials = client.get_role_credentials(
319
+ roleName=sso_role,
320
+ accountId=account_id,
321
+ accessToken=access_token,
322
+ )["roleCredentials"]
323
+ except ClientError as error:
324
+ if error.response["Error"]["Code"] in ("AccessDeniedException", "ForbiddenException"):
325
+ error_msg = (
326
+ f"User does not have permission to assume role [bold]{sso_role}[/bold]"
327
+ " in this account.\nPlease check with your administrator or try"
328
+ " running [bold]leverage aws configure sso[/bold]."
329
+ )
330
+ if raise_on_permission_error:
331
+ raise ExitError(40, error_msg) from error
332
+ logger.warning(f"No permission to assume role [bold]{sso_role}[/bold] in {account_name} account. Skipping.")
333
+ return False
334
+ raise ExitError(50, f"Error retrieving role credentials: {error}") from error
335
+
336
+ # Update expiration on aws/<project>/config
337
+ logger.info(f"Writing {layer_profile} profile")
338
+ update_config_section(
339
+ config_updater,
340
+ f"profile {layer_profile}",
341
+ data={
342
+ "expiration": credentials["expiration"],
343
+ },
344
+ )
345
+
346
+ # Write credentials on aws/<project>/credentials
347
+ update_config_section(
348
+ credentials_updater,
349
+ layer_profile,
350
+ data={
351
+ "aws_access_key_id": credentials["accessKeyId"],
352
+ "aws_secret_access_key": credentials["secretAccessKey"],
353
+ "aws_session_token": credentials["sessionToken"],
354
+ },
355
+ )
356
+
357
+ logger.info(f"Credentials for {account_name} account written successfully.")
358
+ return True
359
+
360
+
361
+ def _get_sso_token_or_raise(paths: PathsHandler) -> str:
362
+ """Get the SSO access token, or raise a friendly ExitError on failure.
363
+
364
+ Wraps file/JSON/key errors so callers (e.g. `leverage aws sso refresh` invoked without
365
+ a prior `check_sso_token`) get a clear message instead of a raw stacktrace.
366
+ """
367
+ try:
368
+ return get_sso_access_token(paths.sso_token_file)
369
+ except (OSError, ValueError, KeyError) as e:
370
+ raise ExitError(1, f"Failed to get SSO access token: {e}") from e
371
+
372
+
373
+ def refresh_all_accounts_credentials(paths: PathsHandler, force_refresh: bool = False) -> None:
374
+ """
375
+ Refresh credentials for all configured SSO accounts.
376
+
377
+ Iterates through every `profile <project>-sso-<account>` section in the AWS config
378
+ file and refreshes their credentials using the current SSO access token.
379
+
380
+ Args:
381
+ paths: PathsHandler instance with access to project paths and configuration.
382
+ force_refresh: If True, refresh all credentials regardless of expiration.
383
+ If False, only refresh expired credentials.
384
+ """
385
+ logger.info("Refreshing credentials for all accounts...")
386
+
387
+ config_updater = ConfigUpdater()
388
+ config_updater.read(paths.aws_config_file)
389
+ # SSO account profiles use the section name "profile <project>-sso-<account>"; the trailing
390
+ # dash naturally excludes the parent "profile <project>-sso" section.
391
+ sso_prefix = f"profile {paths.project}-sso-"
392
+ sso_profiles = [s for s in config_updater.sections() if s.startswith(sso_prefix)]
393
+
394
+ if not sso_profiles:
395
+ logger.warning("No SSO account profiles found. Run 'leverage aws configure sso' first.")
396
+ return
397
+
398
+ logger.info(f"Found {len(sso_profiles)} account(s) to refresh.")
399
+ region = config_updater.get(f"profile {paths.project}-sso", "sso_region").value
400
+ client = boto3.client("sso", region_name=region)
401
+ access_token = _get_sso_token_or_raise(paths)
402
+ credentials_updater = _get_credentials_updater(paths)
403
+
404
+ refreshed = skipped = failed = 0
405
+ for profile_section in sso_profiles:
196
406
  try:
197
- expiration = int(config_updater.get(f"profile {layer_profile}", "expiration").value) / 1000
198
- except (NoSectionError, NoOptionError):
199
- # first time using this profile, skip into the credential's retrieval step
200
- logger.debug("No cached credentials found.")
201
- else:
202
- # we reduce the validity 30 minutes, to avoid expiration over long-standing tasks
203
- renewal = time.time() + (30 * 60)
204
- logger.debug(f"Token expiration time: {expiration}")
205
- logger.debug(f"Token renewal time: {renewal}")
206
- if renewal < expiration:
207
- # still valid, nothing to do with these profile!
208
- logger.info("Using already configured temporary credentials.")
407
+ account_id, account_name, sso_role, layer_profile = _get_sso_profile_from_section(
408
+ config_updater, profile_section, paths.project
409
+ )
410
+ if _check_credentials_expiration(config_updater, layer_profile, force_refresh=force_refresh):
411
+ logger.info(f"Credentials for {account_name} account are still valid, skipping.")
412
+ skipped += 1
209
413
  continue
210
414
 
211
- # retrieve credentials
212
- logger.debug(f"Retrieving role credentials for {sso_role}...")
213
- try:
214
- credentials = client.get_role_credentials(
215
- roleName=sso_role,
216
- accountId=account_id,
217
- accessToken=get_sso_access_token(paths.sso_token_file),
218
- )["roleCredentials"]
219
- except ClientError as error:
220
- if error.response["Error"]["Code"] in ("AccessDeniedException", "ForbiddenException"):
221
- raise ExitError(
222
- 40,
223
- f"User does not have permission to assume role [bold]{sso_role}[/bold]"
224
- " in this account.\nPlease check with your administrator or try"
225
- " running [bold]leverage aws configure sso[/bold].",
226
- )
415
+ logger.info(f"Retrieving credentials for {account_name} account...")
416
+ if _retrieve_and_update_credentials(
417
+ paths=paths,
418
+ client=client,
419
+ access_token=access_token,
420
+ account_id=account_id,
421
+ sso_role=sso_role,
422
+ layer_profile=layer_profile,
423
+ account_name=account_name,
424
+ config_updater=config_updater,
425
+ credentials_updater=credentials_updater,
426
+ raise_on_permission_error=False,
427
+ ):
428
+ logger.info(f"Credentials for {account_name} account refreshed successfully.")
429
+ refreshed += 1
227
430
  else:
228
- raise ExitError(50, f"Error retrieving role credentials: {error}")
431
+ failed += 1
432
+ except Exception as e:
433
+ logger.exception(f"Failed to refresh credentials for {profile_section}: {e}")
434
+ failed += 1
229
435
 
230
- # update expiration on aws/<project>/config
231
- logger.info(f"Writing {layer_profile} profile")
232
- update_config_section(
233
- config_updater,
234
- f"profile {layer_profile}",
235
- data={
236
- "expiration": credentials["expiration"],
237
- },
238
- )
239
- # write credentials on aws/<project>/credentials (create the file if it doesn't exist first)
240
- paths.aws_credentials_file.touch(exist_ok=True)
241
- credentials_updater = ConfigUpdater()
242
- credentials_updater.read(paths.aws_credentials_file)
243
-
244
- update_config_section(
245
- credentials_updater,
246
- layer_profile,
247
- data={
248
- "aws_access_key_id": credentials["accessKeyId"],
249
- "aws_secret_access_key": credentials["secretAccessKey"],
250
- "aws_session_token": credentials["sessionToken"],
251
- },
252
- )
253
- logger.info(f"Credentials for {account_name} account written successfully.")
436
+ logger.info(f"Credential refresh complete: {refreshed} refreshed, {skipped} skipped, {failed} failed.")
254
437
 
255
438
 
256
439
  def refresh_layer_credentials_mfa(paths: PathsHandler):
@@ -15,7 +15,7 @@ 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, refresh_all_accounts_credentials
19
19
  from leverage.modules.auth import _perform_authentication as perform_authentication
20
20
 
21
21
 
@@ -217,9 +217,15 @@ def sso(context: click.Context, awscli: Runner, args: Tuple[str, ...]) -> None:
217
217
 
218
218
 
219
219
  @sso.command()
220
+ @click.option(
221
+ "--refresh-all",
222
+ is_flag=True,
223
+ default=False,
224
+ help="Automatically refresh credentials for all configured accounts after login.",
225
+ )
220
226
  @pass_paths
221
227
  @pass_runner
222
- def login(awscli: Runner, paths: PathsHandler) -> None:
228
+ def login(awscli: Runner, paths: PathsHandler, refresh_all: bool) -> None:
223
229
  """Login"""
224
230
  exit_code, region, _ = awscli.exec("configure", "get", "sso_region", "--profile", f"{paths.project}-sso")
225
231
  if exit_code:
@@ -299,6 +305,22 @@ def login(awscli: Runner, paths: PathsHandler) -> None:
299
305
 
300
306
  logger.info(f"Successfully logged in!.")
301
307
 
308
+ if refresh_all:
309
+ refresh_all_accounts_credentials(paths, force_refresh=False)
310
+
311
+
312
+ @sso.command()
313
+ @click.option(
314
+ "--force",
315
+ is_flag=True,
316
+ default=False,
317
+ help="Force refresh all credentials, even if they haven't expired.",
318
+ )
319
+ @pass_paths
320
+ def refresh(paths: PathsHandler, force: bool) -> None:
321
+ """Refresh credentials for all configured accounts"""
322
+ refresh_all_accounts_credentials(paths, force_refresh=force)
323
+
302
324
 
303
325
  @sso.command()
304
326
  @pass_paths
@@ -24,7 +24,13 @@ def _handle_subcommand(
24
24
  Raises:
25
25
  Exit: Whenever runner execution returns a non-zero exit code
26
26
  """
27
- caller_pos = args.index(caller_name) if caller_name is not None else 0
27
+ # When this function is called from a click subcommand callback (e.g. the `sso` group),
28
+ # `args` is the subcontext's args slice and no longer contains the caller's own name. Treat
29
+ # that as "search from the start" rather than crashing on ValueError.
30
+ try:
31
+ caller_pos = args.index(caller_name) if caller_name is not None else 0
32
+ except ValueError:
33
+ caller_pos = 0
28
34
 
29
35
  # Find if one of the wrapped subcommand was invoked
30
36
  wrapped_subcommands = context.command.commands.keys()
@@ -38,12 +44,15 @@ def _handle_subcommand(
38
44
  raise Exit(exit_code)
39
45
 
40
46
  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
47
+ # Build the subcommand's own context from its slice of the CLI args and invoke it through that
48
+ # context. We can't use context.invoke / context.forward here: invoke ignores user-supplied
49
+ # options (everything falls back to defaults) and forward copies the parent group's params
50
+ # (notably `args`) into the child callback's kwargs, which raises TypeError as soon as the
51
+ # child has its own click options (e.g. `sso login --refresh-all`, `sso refresh --force`).
52
+ sub_ctx = subcommand.make_context(
53
+ info_name=subcommand.name,
54
+ args=list(args)[args.index(subcommand.name) + 1 :],
55
+ parent=context,
44
56
  )
45
- # Invoke wrapped command
46
- if not subcommand.params:
47
- context.invoke(subcommand)
48
- else:
49
- context.forward(subcommand)
57
+ with sub_ctx:
58
+ sub_ctx.command.invoke(sub_ctx)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "leverage"
3
- version = "3.0.1rc0"
3
+ version = "3.1.0"
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
File without changes
File without changes
File without changes
File without changes
File without changes