leverage 2.1.2rc2__tar.gz → 2.2.0rc1__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 (32) hide show
  1. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/PKG-INFO +2 -1
  2. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/__init__.py +1 -7
  3. leverage-2.2.0rc1/leverage/_backend_config.py +172 -0
  4. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/_internals.py +18 -4
  5. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/_utils.py +4 -4
  6. leverage-2.2.0rc1/leverage/leverage.py +50 -0
  7. leverage-2.2.0rc1/leverage/modules/__init__.py +9 -0
  8. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/modules/auth.py +62 -13
  9. leverage-2.2.0rc1/leverage/modules/aws.py +338 -0
  10. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/modules/credentials.py +0 -3
  11. leverage-2.2.0rc1/leverage/modules/runner.py +124 -0
  12. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/modules/tf.py +178 -181
  13. leverage-2.2.0rc1/leverage/modules/tfrunner.py +72 -0
  14. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/modules/utils.py +12 -15
  15. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/path.py +14 -59
  16. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/pyproject.toml +3 -1
  17. leverage-2.1.2rc2/leverage/leverage.py +0 -68
  18. leverage-2.1.2rc2/leverage/modules/__init__.py +0 -6
  19. leverage-2.1.2rc2/leverage/modules/aws.py +0 -225
  20. leverage-2.1.2rc2/leverage/modules/shell.py +0 -44
  21. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/LICENSE.txt +0 -0
  22. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/README.md +0 -0
  23. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/_parsing.py +0 -0
  24. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/conf.py +0 -0
  25. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/container.py +0 -0
  26. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/containers/kubectl.py +0 -0
  27. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/logger.py +0 -0
  28. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/modules/kubectl.py +0 -0
  29. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/modules/project.py +0 -0
  30. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/modules/run.py +0 -0
  31. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/modules/tfautomv.py +0 -0
  32. {leverage-2.1.2rc2 → leverage-2.2.0rc1}/leverage/tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: leverage
3
- Version: 2.1.2rc2
3
+ Version: 2.2.0rc1
4
4
  Summary: Binbash Leverage Command-Line tool.
5
5
  Home-page: https://github.com/binbashar/leverage
6
6
  License: MIT
@@ -25,6 +25,7 @@ Requires-Dist: docker (==6.1.0)
25
25
  Requires-Dist: dockerpty (==0.4.1)
26
26
  Requires-Dist: docutils (==0.17.1)
27
27
  Requires-Dist: jinja2 (==3.0.1)
28
+ Requires-Dist: python-dateutil (>=2.9.0.post0,<3.0.0)
28
29
  Requires-Dist: python-hcl2 (==7.3.1)
29
30
  Requires-Dist: questionary (==1.10.0)
30
31
  Requires-Dist: requests (==2.31)
@@ -4,13 +4,7 @@
4
4
 
5
5
  # pylint: disable=wrong-import-position
6
6
 
7
- __version__ = "2.1.2rc2"
8
- __toolbox_version__ = "1.3.5-0.2.0"
9
-
10
- MINIMUM_VERSIONS = {
11
- "TERRAFORM": "1.3.5",
12
- "TOOLBOX": "0.2.4",
13
- }
7
+ __version__ = "2.2.0rc1"
14
8
 
15
9
  import sys
16
10
  from shutil import which
@@ -0,0 +1,172 @@
1
+ """Utilities for modifying Terraform backend configuration."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Union, Optional
6
+
7
+ import hcl2
8
+ import lark
9
+
10
+ from leverage._utils import ExitError
11
+
12
+
13
+ def set_backend_key(config_file_path: Union[str, Path], key: str) -> None:
14
+ """
15
+ Set or update the backend key in a Terraform config.tf file.
16
+
17
+ This function modifies the Terraform backend configuration to set the S3 state key.
18
+ It preserves all comments, formatting, and other HCL code in the file by using
19
+ string manipulation to surgically modify only the key attribute.
20
+
21
+ Args:
22
+ config_file_path: Path to the config.tf file
23
+ key: The backend key value to set (e.g., "apps-devstg/notifications/terraform.tfstate")
24
+
25
+ Raises:
26
+ ExitError: If the file cannot be found, cannot be parsed, or does not contain a
27
+ terraform block with an S3 backend
28
+ """
29
+ config_file_path = Path(config_file_path)
30
+
31
+ # Validate file exists
32
+ if not config_file_path.exists():
33
+ raise ExitError(1, f"Config file not found: {config_file_path}")
34
+
35
+ # Read the file content
36
+ content = config_file_path.read_text()
37
+
38
+ # Parse the config file to validate structure
39
+ try:
40
+ config_tf = hcl2.loads(content)
41
+ except Exception as e:
42
+ raise ExitError(1, f"Failed to parse config.tf: {e}")
43
+
44
+ # Validate that the file contains a terraform backend block with S3
45
+ if not (
46
+ "terraform" in config_tf
47
+ and config_tf["terraform"]
48
+ and isinstance(config_tf["terraform"], list)
49
+ and "backend" in config_tf["terraform"][0]
50
+ and config_tf["terraform"][0]["backend"]
51
+ and isinstance(config_tf["terraform"][0]["backend"], list)
52
+ and "s3" in config_tf["terraform"][0]["backend"][0]
53
+ ):
54
+ raise ExitError(
55
+ 1,
56
+ f"Malformed config.tf: File must contain a terraform block with an S3 backend. "
57
+ f"Expected structure:\n"
58
+ f"terraform {{\n"
59
+ f' backend "s3" {{\n'
60
+ f" # configuration\n"
61
+ f" }}\n"
62
+ f"}}",
63
+ )
64
+
65
+ # Check if key already exists
66
+ backend_config = config_tf["terraform"][0]["backend"][0]["s3"]
67
+ key_exists = "key" in backend_config
68
+
69
+ # Modify the file content
70
+ modified_content = _modify_backend_key(content, key, key_exists)
71
+
72
+ # Write back to file
73
+ config_file_path.write_text(modified_content)
74
+
75
+
76
+ def _modify_backend_key(content: str, key: str, key_exists: bool) -> str:
77
+ """
78
+ Modify the backend key in the HCL content.
79
+
80
+ Args:
81
+ content: The original file content
82
+ key: The new key value
83
+ key_exists: Whether the key attribute already exists
84
+
85
+ Returns:
86
+ Modified content with the key set
87
+ """
88
+ # Pattern to find the backend "s3" block
89
+ # This matches: backend "s3" { ... }
90
+ backend_pattern = r'(backend\s+"s3"\s*\{)'
91
+
92
+ if key_exists:
93
+ # Update existing key
94
+ # Match: key = "anything" or key = "anything" with various whitespace/quotes
95
+ key_pattern = r'(\s*key\s*=\s*)"[^"]*"'
96
+ replacement = r'\1"' + key + '"'
97
+
98
+ modified = re.sub(key_pattern, replacement, content)
99
+
100
+ return modified
101
+ else:
102
+ # Add new key attribute
103
+ # Find the backend "s3" block and add the key after the opening brace
104
+ def add_key(match):
105
+ # Get the matched backend opening
106
+ backend_opening = match.group(1)
107
+
108
+ # Find the indentation by looking at the next line
109
+ remaining = content[match.end() :]
110
+ next_line_match = re.search(r"\n(\s*)", remaining)
111
+ if next_line_match:
112
+ indent = next_line_match.group(1)
113
+ else:
114
+ indent = " " # Default to 4 spaces
115
+
116
+ # Add the key attribute with proper indentation
117
+ return f'{backend_opening}\n{indent}key = "{key}"'
118
+
119
+ modified = re.sub(backend_pattern, add_key, content, count=1)
120
+
121
+ return modified
122
+
123
+
124
+ def get_backend_key(config_file: Union[str, Path]) -> Optional[str]:
125
+ """
126
+ Get the current backend key from a Terraform config.tf file.
127
+
128
+ Args:
129
+ config_file: Path to the config.tf file
130
+
131
+ Returns:
132
+ The backend key if it exists, None otherwise
133
+
134
+ Example:
135
+ >>> get_backend_key("/path/to/config.tf")
136
+ 'apps-devstg/layer/terraform.tfstate'
137
+ """
138
+ config_file = Path(config_file)
139
+
140
+ if not config_file.exists():
141
+ raise ExitError(1, f"Config file not found: {config_file}")
142
+
143
+ try:
144
+ config_content = config_file.read_text()
145
+ config_tf = hcl2.loads(config_content)
146
+
147
+ if (
148
+ "terraform" in config_tf
149
+ and config_tf["terraform"]
150
+ and isinstance(config_tf["terraform"], list)
151
+ and "backend" in config_tf["terraform"][0]
152
+ and config_tf["terraform"][0]["backend"]
153
+ and isinstance(config_tf["terraform"][0]["backend"], list)
154
+ and "s3" in config_tf["terraform"][0]["backend"][0]
155
+ ):
156
+ return config_tf["terraform"][0]["backend"][0]["s3"].get("key")
157
+ else:
158
+ raise ExitError(
159
+ 1,
160
+ f"Malformed [bold]config.tf[/bold] file. Missing backend block.\n"
161
+ f"In some cases you may want to skip this check by using the --skip-validation flag, "
162
+ f"e.g. the first time you initialize a tf-backend layer.",
163
+ )
164
+
165
+ except lark.exceptions.UnexpectedInput as error:
166
+ raise ExitError(
167
+ 1,
168
+ f"Possible invalid expression in [bold]config.tf[/bold] near line {error.line}, column {error.column}\n"
169
+ f"{error.get_context(config_content)}",
170
+ )
171
+ except Exception:
172
+ raise ExitError(1, f"Malformed [bold]config.tf[/bold] file. Unable to parse.")
@@ -27,7 +27,9 @@ class State:
27
27
  def __init__(self):
28
28
  self._verbosity = None
29
29
  self.module = Module()
30
- self.container = None
30
+ self.config = None
31
+ self.paths = None
32
+ self.runner = None
31
33
 
32
34
  @property
33
35
  def verbosity(self):
@@ -41,13 +43,25 @@ class State:
41
43
  pass_state = click.make_pass_decorator(State, ensure=True)
42
44
 
43
45
 
44
- def pass_container(command):
45
- """Decorator to pass the current container to the command."""
46
+ def pass_runner(command):
47
+ """Decorator to pass the current runner (Terraform/OpenTofu runner) to the command."""
46
48
 
47
49
  @wraps(command)
48
50
  def new_command(*args, **kwargs):
49
51
  ctx = click.get_current_context()
50
52
 
51
- return command(ctx.obj.container, *args, **kwargs)
53
+ return command(ctx.obj.runner, *args, **kwargs)
54
+
55
+ return new_command
56
+
57
+
58
+ def pass_paths(command):
59
+ """Decorator to pass the current project paths to the command."""
60
+
61
+ @wraps(command)
62
+ def new_command(*args, **kwargs):
63
+ ctx = click.get_current_context()
64
+
65
+ return command(ctx.obj.paths, *args, **kwargs)
52
66
 
53
67
  return new_command
@@ -3,8 +3,8 @@
3
3
  """
4
4
 
5
5
  from pathlib import Path
6
- from subprocess import run
7
- from subprocess import PIPE
6
+ from subprocess import PIPE, run
7
+ from typing import List, Optional
8
8
 
9
9
  import hcl2
10
10
  import lark
@@ -153,12 +153,12 @@ class ContainerSession:
153
153
  self.docker_client.api.remove_container(self.container_data)
154
154
 
155
155
 
156
- def key_finder(d: dict, target: str, avoid: str = None):
156
+ def key_finder(d: dict, target: str, avoid: Optional[str] = None) -> List[str]:
157
157
  """
158
158
  Iterate over a dict of dicts and/or lists of dicts, looking for a key with value "target".
159
159
  Collect and return all the values that matches "target" as key.
160
160
  """
161
- values = []
161
+ values: List[str] = []
162
162
 
163
163
  for key, value in d.items():
164
164
  if isinstance(value, dict):
@@ -0,0 +1,50 @@
1
+ """
2
+ Binbash Leverage Command-line tool.
3
+ """
4
+
5
+ import rich
6
+ from packaging.version import Version
7
+
8
+ import click
9
+
10
+ from leverage import __version__, conf
11
+ from leverage._internals import pass_state
12
+
13
+ # from leverage.modules.credentials import credentials
14
+ from leverage.modules import aws, run, tofu, terraform
15
+
16
+ # from leverage.modules import run, project, tofu, terraform, tfautomv, kubectl, shell
17
+ from leverage.path import NotARepositoryError, PathsHandler
18
+
19
+
20
+ @click.group(invoke_without_command=True)
21
+ @click.option("-v", "--verbose", is_flag=True, help="Increase output verbosity.")
22
+ @click.version_option(version=__version__)
23
+ @pass_state
24
+ @click.pass_context
25
+ def leverage(context, state, verbose):
26
+ """Leverage Reference Architecture projects command-line tool."""
27
+ # --verbose | -v
28
+ state.verbosity = verbose
29
+ if context.invoked_subcommand is None:
30
+ # leverage called with no subcommand
31
+ click.echo(context.get_help())
32
+
33
+ try:
34
+ state.config = conf.load()
35
+ except NotARepositoryError:
36
+ return
37
+ state.paths = PathsHandler(state.config)
38
+
39
+
40
+ # Add modules to leverage
41
+ leverage.add_command(run)
42
+ # leverage.add_command(project)
43
+ leverage.add_command(tofu)
44
+ leverage.add_command(tofu, name="tf")
45
+ leverage.add_command(terraform)
46
+ # leverage.add_command(credentials)
47
+ leverage.add_command(aws)
48
+ # leverage.add_command(tfautomv)
49
+ # leverage.add_command(kubectl)
50
+ # leverage.add_command(kubectl, name="kc")
@@ -0,0 +1,9 @@
1
+ from .run import run
2
+
3
+ # from .project import project
4
+ from .tf import tofu, terraform
5
+ from .aws import aws
6
+
7
+ # from .tfautomv import tfautomv
8
+ # from .kubectl import kubectl
9
+ # from .shell import shell
@@ -1,5 +1,8 @@
1
1
  import time
2
+ import json
2
3
  from pathlib import Path
4
+ from datetime import datetime
5
+ from dateutil.tz import tzutc
3
6
  from configparser import NoSectionError, NoOptionError
4
7
 
5
8
  import boto3
@@ -7,6 +10,7 @@ from botocore.exceptions import ClientError
7
10
  from configupdater import ConfigUpdater
8
11
 
9
12
  from leverage import logger
13
+ from leverage.path import PathsHandler
10
14
  from leverage._utils import key_finder, ExitError, get_or_create_section, parse_tf_file
11
15
 
12
16
 
@@ -24,7 +28,11 @@ def get_layer_profile(raw_profile: str, config_updater: ConfigUpdater, tf_profil
24
28
  layer_profile = tf_profile if raw_profile in ("${var.profile}", "each.value.profile") else None
25
29
 
26
30
  # replace variables with their corresponding values
27
- raw = raw_profile.replace("${var.profile}", tf_profile).replace("${var.project}", project).replace("each.value.profile", tf_profile).replace("each.value.project", project)
31
+ raw = (
32
+ raw_profile.replace("${var.profile}", tf_profile)
33
+ .replace("${var.project}", project)
34
+ .replace("each.value.profile", tf_profile)
35
+ )
28
36
 
29
37
  # the project and the role are at the beginning and end of the string
30
38
  _, *account_name, _ = raw.split("-")
@@ -57,7 +65,7 @@ def update_config_section(updater: ConfigUpdater, layer_profile: str, data: dict
57
65
  updater.update_file()
58
66
 
59
67
 
60
- def get_profiles(cli):
68
+ def get_profiles(paths: PathsHandler):
61
69
  """
62
70
  Get the AWS profiles present on the layer by parsing some tf files.
63
71
  """
@@ -65,7 +73,7 @@ def get_profiles(cli):
65
73
  # these are files from the layer we are currently on
66
74
  for name in ("config.tf", "locals.tf", "runtime.tf"):
67
75
  try:
68
- tf_config = parse_tf_file(Path(cli.paths.cwd / name))
76
+ tf_config = parse_tf_file(Path(paths.cwd / name))
69
77
  except FileNotFoundError:
70
78
  continue
71
79
 
@@ -74,25 +82,65 @@ def get_profiles(cli):
74
82
  raw_profiles.update(set(key_finder(tf_config, "profile", "lookup")))
75
83
 
76
84
  # the profile value from <layer>/config/backend.tfvars
77
- backend_config = parse_tf_file(cli.paths.local_backend_tfvars)
85
+ backend_config = parse_tf_file(paths.backend_tfvars)
78
86
  tf_profile = backend_config["profile"]
79
87
 
80
88
  return tf_profile, raw_profiles
81
89
 
82
90
 
83
- def refresh_layer_credentials(cli):
84
- tf_profile, raw_profiles = get_profiles(cli)
91
+ def get_sso_access_token(sso_token_file: Path) -> str:
92
+ """
93
+ Get the SSO access token from the token file.
94
+ """
95
+ return json.loads(sso_token_file.read_text())["accessToken"]
96
+
97
+
98
+ def check_sso_token(paths: PathsHandler):
99
+ """Check for the existence and validity of the SSO token to be used to get credentials."""
100
+
101
+ # Adding `token` file name to this function in order to
102
+ # meet the requirement regarding to have just one
103
+ # token file in the sso/cache
104
+ sso_role = paths.account_conf.get("sso_role")
105
+ token_file = paths.sso_cache / sso_role
106
+
107
+ token_files = list(paths.sso_cache.glob("*"))
108
+ if not token_files:
109
+ raise ExitError(1, "No AWS SSO token found. Please log in or configure SSO.")
110
+
111
+ if token_file not in token_files and paths.sso_token_file not in token_files:
112
+ raise ExitError(
113
+ 1,
114
+ "No valid AWS SSO token found for current account.\n"
115
+ "Please log out and reconfigure SSO before proceeding.",
116
+ )
117
+
118
+ token = json.loads(paths.sso_token_file.read_text())
119
+ expiry = datetime.strptime(token.get("expiresAt"), "%Y-%m-%dT%H:%M:%SZ")
120
+ renewal = datetime.now()
121
+
122
+ if expiry < renewal:
123
+ raise ExitError(
124
+ 1,
125
+ "AWS SSO token has expired, please log back in by running [bold]leverage aws sso login[/bold]"
126
+ " to refresh your credentials before re-running the last command.",
127
+ )
128
+
129
+
130
+ def refresh_layer_credentials(paths: PathsHandler):
131
+ tf_profile, raw_profiles = get_profiles(paths)
85
132
  config_updater = ConfigUpdater()
86
- config_updater.read(cli.paths.host_aws_profiles_file)
133
+ config_updater.read(paths.aws_config_file)
87
134
 
88
- client = boto3.client("sso", region_name=cli.sso_region_from_main_profile)
135
+ region = config_updater.get(f"profile {paths.project}-sso", "sso_region").value
136
+ client = boto3.client("sso", region_name=region)
89
137
  for raw in raw_profiles:
90
138
  try:
91
139
  account_id, account_name, sso_role, layer_profile = get_layer_profile(
92
140
  raw,
93
141
  config_updater,
94
142
  tf_profile,
95
- cli.project,
143
+ paths.project,
96
144
  )
97
145
  except SkipProfile:
98
146
  continue
@@ -119,7 +167,7 @@ def refresh_layer_credentials(cli):
119
167
  credentials = client.get_role_credentials(
120
168
  roleName=sso_role,
121
169
  accountId=account_id,
122
- accessToken=cli.get_sso_access_token(),
170
+ accessToken=get_sso_access_token(paths.sso_token_file),
123
171
  )["roleCredentials"]
124
172
  except ClientError as error:
125
173
  if error.response["Error"]["Code"] in ("AccessDeniedException", "ForbiddenException"):
@@ -129,6 +177,8 @@ def refresh_layer_credentials(cli):
129
177
  " in this account.\nPlease check with your administrator or try"
130
178
  " running [bold]leverage aws configure sso[/bold].",
131
179
  )
180
+ else:
181
+ raise ExitError(50, f"Error retrieving role credentials: {error}")
132
182
 
133
183
  # update expiration on aws/<project>/config
134
184
  logger.info(f"Writing {layer_profile} profile")
@@ -140,10 +190,9 @@ def refresh_layer_credentials(cli):
140
190
  },
141
191
  )
142
192
  # write credentials on aws/<project>/credentials (create the file if it doesn't exist first)
143
- creds_path = Path(cli.paths.host_aws_credentials_file)
144
- creds_path.touch(exist_ok=True)
193
+ paths.aws_credentials_file.touch(exist_ok=True)
145
194
  credentials_updater = ConfigUpdater()
146
- credentials_updater.read(cli.paths.host_aws_credentials_file)
195
+ credentials_updater.read(paths.aws_credentials_file)
147
196
 
148
197
  update_config_section(
149
198
  credentials_updater,