leverage 3.1.0__tar.gz → 3.2.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.1.0 → leverage-3.2.0}/PKG-INFO +31 -28
  2. {leverage-3.1.0 → leverage-3.2.0}/README.md +30 -27
  3. {leverage-3.1.0 → leverage-3.2.0}/leverage/__init__.py +1 -1
  4. {leverage-3.1.0 → leverage-3.2.0}/leverage/leverage.py +16 -6
  5. {leverage-3.1.0 → leverage-3.2.0}/leverage/logger.py +1 -1
  6. {leverage-3.1.0 → leverage-3.2.0}/leverage/modules/credentials.py +95 -14
  7. {leverage-3.1.0 → leverage-3.2.0}/leverage/modules/tf.py +27 -17
  8. {leverage-3.1.0 → leverage-3.2.0}/leverage/modules/tfautomv.py +1 -1
  9. {leverage-3.1.0 → leverage-3.2.0}/leverage/path.py +27 -3
  10. {leverage-3.1.0 → leverage-3.2.0}/pyproject.toml +1 -1
  11. {leverage-3.1.0 → leverage-3.2.0}/LICENSE.txt +0 -0
  12. {leverage-3.1.0 → leverage-3.2.0}/leverage/_backend_config.py +0 -0
  13. {leverage-3.1.0 → leverage-3.2.0}/leverage/_internals.py +0 -0
  14. {leverage-3.1.0 → leverage-3.2.0}/leverage/_parsing.py +0 -0
  15. {leverage-3.1.0 → leverage-3.2.0}/leverage/_utils.py +0 -0
  16. {leverage-3.1.0 → leverage-3.2.0}/leverage/conf.py +0 -0
  17. {leverage-3.1.0 → leverage-3.2.0}/leverage/modules/__init__.py +0 -0
  18. {leverage-3.1.0 → leverage-3.2.0}/leverage/modules/auth.py +0 -0
  19. {leverage-3.1.0 → leverage-3.2.0}/leverage/modules/aws.py +0 -0
  20. {leverage-3.1.0 → leverage-3.2.0}/leverage/modules/kubectl.py +0 -0
  21. {leverage-3.1.0 → leverage-3.2.0}/leverage/modules/project.py +0 -0
  22. {leverage-3.1.0 → leverage-3.2.0}/leverage/modules/run.py +0 -0
  23. {leverage-3.1.0 → leverage-3.2.0}/leverage/modules/runner.py +0 -0
  24. {leverage-3.1.0 → leverage-3.2.0}/leverage/modules/tfrunner.py +0 -0
  25. {leverage-3.1.0 → leverage-3.2.0}/leverage/modules/utils.py +0 -0
  26. {leverage-3.1.0 → leverage-3.2.0}/leverage/tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: leverage
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: Binbash Leverage Command-Line tool.
5
5
  Home-page: https://github.com/binbashar/leverage
6
6
  License: MIT
@@ -45,11 +45,9 @@ Description-Content-Type: text/markdown
45
45
 
46
46
  Leverage CLI is the tool used to manage and interact with any Leverage project.
47
47
 
48
- It transparently handles the most complex and error prone tasks that arise from working with a state-of-the-art
49
- infrastructure definition like our Leverage Reference Architecture. Leverage CLI uses a dockerized approach to
50
- encapsulate the tools needed to perform such tasks and to free the user from having to deal with the configuration and
51
- management of said tools.
52
- Provides the means to interact with your Leverage project and allows you to define custom tasks to run.
48
+ It transparently handles the most complex and error-prone tasks that arise from working with a state-of-the-art infrastructure definition like our Leverage Reference Architecture. It understands the structure of the Leverage Reference Architecture and assists the user in their day-to-day operation.
49
+
50
+ Leverage CLI orchestrates a set of tools used to operate in a Leverage project. Usually this means OpenTofu/Terraform, but also aws-cli, kubectl and more.
53
51
 
54
52
  Reviewing and implementing the [Binbash Leverage Landing Zone for AWS](https://leverage.binbash.co/try-leverage/) would
55
53
  be a very good place to start!
@@ -61,10 +59,13 @@ to [this page](https://leverage.binbash.co/user-guide/leverage-cli/installation/
61
59
 
62
60
  ### Note for migration from previous versions
63
61
 
64
- If you come from Leverage CLI version <1.8.0 and want to install Leverage CLI version >= 1.8.0 keep into account the
62
+ If you come from Leverage CLI version <3.0.0 and want to use Leverage CLI version >= 3.0.0 keep into account the
65
63
  following.
66
64
 
67
- The `build.env` file format has changed. As an example, this is the old format:
65
+ Leverage CLI no longer runs tools inside a Docker container; it executes the OpenTofu/Terraform binaries installed on
66
+ your host. Because of this, the `TERRAFORM_IMAGE_NAME`/`TF_IMAGE_NAME` and `TERRAFORM_IMAGE_TAG`/`TF_IMAGE_TAG`
67
+ parameters in the `build.env` file no longer exist. In their place, the optional `TF_BINARY` parameter specifies the path
68
+ to the OpenTofu/Terraform binary to use. As an example, this is the old format:
68
69
 
69
70
  ```
70
71
  # Project settings
@@ -74,8 +75,7 @@ PROJECT=bb
74
75
  MFA_ENABLED=false
75
76
 
76
77
  # Terraform
77
- TERRAFORM_IMAGE_NAME=binbash/terraform-awscli-slim
78
- TERRAFORM_IMAGE_TAG=1.1.9
78
+ TF_IMAGE_TAG=1.5.0-0.2.0
79
79
  ```
80
80
 
81
81
  New version example:
@@ -87,16 +87,19 @@ PROJECT=bb
87
87
  # General
88
88
  MFA_ENABLED=false
89
89
 
90
- # Terraform
91
- TF_IMAGE_TAG=1.5.0-0.2.0
90
+ # OpenTofu/Terraform binary
91
+ TF_BINARY=/usr/local/bin/tofu
92
92
  ```
93
93
 
94
- So, if you have created a project with version <1.8.0 and want to use it with version >=1.8.0 you should:
95
-
96
- - remove TERRAFORM_IMAGE_NAME line
97
- - update TF_IMAGE_TAG from this form '9.9.9' to this one '9.9.9-9.9.9'.
94
+ So, if you have created a project with version <3.0.0 and want to use it with version >=3.0.0 you should:
98
95
 
99
- For the second item you can check the version [here](https://hub.docker.com/r/binbash/leverage-toolbox/tags).
96
+ - remove the `TERRAFORM_IMAGE_NAME`/`TF_IMAGE_NAME` and `TERRAFORM_IMAGE_TAG`/`TF_IMAGE_TAG` lines
97
+ - install [OpenTofu](https://opentofu.org/docs/intro/install/) or [Terraform](https://developer.hashicorp.com/terraform/install)
98
+ on your host
99
+ - optionally, set `TF_BINARY` to the path of the binary. This is useful when you have multiple versions of
100
+ OpenTofu/Terraform installed, or when the binary lives in an unconventional location. It can be an absolute path, a path starting with `~`, or a path
101
+ relative to the project root. If not set, the `tofu` or `terraform` binary is looked up in your `PATH`. Keep in mind
102
+ the binary must match the command you use: `leverage tofu` expects OpenTofu and `leverage terraform` expects Terraform.
100
103
 
101
104
  ## System requirements
102
105
 
@@ -221,12 +224,8 @@ poetry run pre-commit install
221
224
  To run unit tests, pytest is the tool of choice, and the required dependencies are available in the
222
225
  corresponding `dev-requirements.txt`.
223
226
 
224
- Integration tests are implemented using [bats](https://github.com/bats-core/bats-core/). Bear in mind that bats tests
225
- are meant to be run in a throwaway environment since they perform filesystem manipulations and installation and removal
226
- of packages, and the cleanup may not be completely thorough. As such, is highly recommended to run these tests using the
227
- docker image.
228
-
229
- ### Manually
227
+ Integration tests are implemented using [bats](https://github.com/bats-core/bats-core/). They drive the installed
228
+ `leverage` command, and work on temporary directories of their own, so they can be run directly on your machine.
230
229
 
231
230
  1. Unit tests:
232
231
 
@@ -245,17 +244,21 @@ brew install bats-support
245
244
  brew install bats-assert
246
245
  ```
247
246
 
247
+ The cli runs the infrastructure binaries directly, so `terraform` and `tofu` need to be installed as well. See
248
+ [System requirements](#system-requirements).
249
+
248
250
  ```bash
249
251
  bats -r tests/bats
250
252
  ```
251
253
 
252
- ### Using docker image
254
+ If `bats-support` and `bats-assert` are not installed in a location bats searches by default, point `BATS_LIB_PATH` at
255
+ the directory holding them:
253
256
 
254
- A Docker image suitable for running all tests can be crafted by running `make build-image`. After crafting the image all
255
- tests can be executed.
257
+ ```bash
258
+ BATS_LIB_PATH=/opt/homebrew/lib bats -r tests/bats
259
+ ```
256
260
 
257
- To run all tests, run `make tests`. Alternatively `make test-unit` or `make test-int` for unit or integration tests
258
- respectively.
261
+ Alternatively, `make tests` runs both suites, and `make test-unit` or `make test-int` runs one of them.
259
262
 
260
263
  ## Release Process
261
264
 
@@ -7,11 +7,9 @@
7
7
 
8
8
  Leverage CLI is the tool used to manage and interact with any Leverage project.
9
9
 
10
- It transparently handles the most complex and error prone tasks that arise from working with a state-of-the-art
11
- infrastructure definition like our Leverage Reference Architecture. Leverage CLI uses a dockerized approach to
12
- encapsulate the tools needed to perform such tasks and to free the user from having to deal with the configuration and
13
- management of said tools.
14
- Provides the means to interact with your Leverage project and allows you to define custom tasks to run.
10
+ It transparently handles the most complex and error-prone tasks that arise from working with a state-of-the-art infrastructure definition like our Leverage Reference Architecture. It understands the structure of the Leverage Reference Architecture and assists the user in their day-to-day operation.
11
+
12
+ Leverage CLI orchestrates a set of tools used to operate in a Leverage project. Usually this means OpenTofu/Terraform, but also aws-cli, kubectl and more.
15
13
 
16
14
  Reviewing and implementing the [Binbash Leverage Landing Zone for AWS](https://leverage.binbash.co/try-leverage/) would
17
15
  be a very good place to start!
@@ -23,10 +21,13 @@ to [this page](https://leverage.binbash.co/user-guide/leverage-cli/installation/
23
21
 
24
22
  ### Note for migration from previous versions
25
23
 
26
- If you come from Leverage CLI version <1.8.0 and want to install Leverage CLI version >= 1.8.0 keep into account the
24
+ If you come from Leverage CLI version <3.0.0 and want to use Leverage CLI version >= 3.0.0 keep into account the
27
25
  following.
28
26
 
29
- The `build.env` file format has changed. As an example, this is the old format:
27
+ Leverage CLI no longer runs tools inside a Docker container; it executes the OpenTofu/Terraform binaries installed on
28
+ your host. Because of this, the `TERRAFORM_IMAGE_NAME`/`TF_IMAGE_NAME` and `TERRAFORM_IMAGE_TAG`/`TF_IMAGE_TAG`
29
+ parameters in the `build.env` file no longer exist. In their place, the optional `TF_BINARY` parameter specifies the path
30
+ to the OpenTofu/Terraform binary to use. As an example, this is the old format:
30
31
 
31
32
  ```
32
33
  # Project settings
@@ -36,8 +37,7 @@ PROJECT=bb
36
37
  MFA_ENABLED=false
37
38
 
38
39
  # Terraform
39
- TERRAFORM_IMAGE_NAME=binbash/terraform-awscli-slim
40
- TERRAFORM_IMAGE_TAG=1.1.9
40
+ TF_IMAGE_TAG=1.5.0-0.2.0
41
41
  ```
42
42
 
43
43
  New version example:
@@ -49,16 +49,19 @@ PROJECT=bb
49
49
  # General
50
50
  MFA_ENABLED=false
51
51
 
52
- # Terraform
53
- TF_IMAGE_TAG=1.5.0-0.2.0
52
+ # OpenTofu/Terraform binary
53
+ TF_BINARY=/usr/local/bin/tofu
54
54
  ```
55
55
 
56
- So, if you have created a project with version <1.8.0 and want to use it with version >=1.8.0 you should:
57
-
58
- - remove TERRAFORM_IMAGE_NAME line
59
- - update TF_IMAGE_TAG from this form '9.9.9' to this one '9.9.9-9.9.9'.
56
+ So, if you have created a project with version <3.0.0 and want to use it with version >=3.0.0 you should:
60
57
 
61
- For the second item you can check the version [here](https://hub.docker.com/r/binbash/leverage-toolbox/tags).
58
+ - remove the `TERRAFORM_IMAGE_NAME`/`TF_IMAGE_NAME` and `TERRAFORM_IMAGE_TAG`/`TF_IMAGE_TAG` lines
59
+ - install [OpenTofu](https://opentofu.org/docs/intro/install/) or [Terraform](https://developer.hashicorp.com/terraform/install)
60
+ on your host
61
+ - optionally, set `TF_BINARY` to the path of the binary. This is useful when you have multiple versions of
62
+ OpenTofu/Terraform installed, or when the binary lives in an unconventional location. It can be an absolute path, a path starting with `~`, or a path
63
+ relative to the project root. If not set, the `tofu` or `terraform` binary is looked up in your `PATH`. Keep in mind
64
+ the binary must match the command you use: `leverage tofu` expects OpenTofu and `leverage terraform` expects Terraform.
62
65
 
63
66
  ## System requirements
64
67
 
@@ -183,12 +186,8 @@ poetry run pre-commit install
183
186
  To run unit tests, pytest is the tool of choice, and the required dependencies are available in the
184
187
  corresponding `dev-requirements.txt`.
185
188
 
186
- Integration tests are implemented using [bats](https://github.com/bats-core/bats-core/). Bear in mind that bats tests
187
- are meant to be run in a throwaway environment since they perform filesystem manipulations and installation and removal
188
- of packages, and the cleanup may not be completely thorough. As such, is highly recommended to run these tests using the
189
- docker image.
190
-
191
- ### Manually
189
+ Integration tests are implemented using [bats](https://github.com/bats-core/bats-core/). They drive the installed
190
+ `leverage` command, and work on temporary directories of their own, so they can be run directly on your machine.
192
191
 
193
192
  1. Unit tests:
194
193
 
@@ -207,17 +206,21 @@ brew install bats-support
207
206
  brew install bats-assert
208
207
  ```
209
208
 
209
+ The cli runs the infrastructure binaries directly, so `terraform` and `tofu` need to be installed as well. See
210
+ [System requirements](#system-requirements).
211
+
210
212
  ```bash
211
213
  bats -r tests/bats
212
214
  ```
213
215
 
214
- ### Using docker image
216
+ If `bats-support` and `bats-assert` are not installed in a location bats searches by default, point `BATS_LIB_PATH` at
217
+ the directory holding them:
215
218
 
216
- A Docker image suitable for running all tests can be crafted by running `make build-image`. After crafting the image all
217
- tests can be executed.
219
+ ```bash
220
+ BATS_LIB_PATH=/opt/homebrew/lib bats -r tests/bats
221
+ ```
218
222
 
219
- To run all tests, run `make tests`. Alternatively `make test-unit` or `make test-int` for unit or integration tests
220
- respectively.
223
+ Alternatively, `make tests` runs both suites, and `make test-unit` or `make test-int` runs one of them.
221
224
 
222
225
  ## Release Process
223
226
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  # pylint: disable=wrong-import-position
6
6
 
7
- __version__ = "3.1.0"
7
+ __version__ = "3.2.0"
8
8
 
9
9
  import sys
10
10
  from shutil import which
@@ -6,7 +6,7 @@ import click
6
6
 
7
7
  from leverage import __version__, conf
8
8
  from leverage._internals import pass_state
9
- from leverage.path import NotARepositoryError, PathsHandler
9
+ from leverage.path import NotARepositoryError, build_paths_and_environment, is_project_yaml_only_bootstrap
10
10
  from leverage.modules import aws, credentials, run, project, tofu, terraform, tfautomv, kubectl
11
11
 
12
12
 
@@ -27,11 +27,21 @@ def leverage(context, state, verbose):
27
27
  state.config = conf.load()
28
28
  except NotARepositoryError:
29
29
  return
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
- }
30
+
31
+ # The `project` commands bootstrap a project, so they run before its configuration exists.
32
+ # `project init` creates the git repository, so from that point on the config loads fine but
33
+ # still holds no project name, and building the paths would fail on a legitimate invocation.
34
+ if context.invoked_subcommand == project.name:
35
+ return
36
+
37
+ # `credentials configure` can legitimately run right after `project init` and before
38
+ # `project create`: only project.yaml exists, so no project name can be resolved yet and
39
+ # PathsHandler would abort. The `credentials` group callback derives the project name from
40
+ # project.yaml and builds state.paths itself in that case.
41
+ if context.invoked_subcommand == credentials.name and is_project_yaml_only_bootstrap():
42
+ return
43
+
44
+ state.paths, state.environment = build_paths_and_environment(state.config)
35
45
 
36
46
 
37
47
  # Add modules to leverage
@@ -174,7 +174,7 @@ def get_tasks_logger():
174
174
 
175
175
  def _raw_logger():
176
176
  """
177
- Provide a raw logger, in case we need to print stuff that already comes formatted (like some container logs).
177
+ Provide a raw logger, in case we need to print stuff that already comes formatted.
178
178
  """
179
179
  logger = logging.getLogger("raw")
180
180
  logger.setLevel(logging.INFO)
@@ -16,13 +16,14 @@ from ruamel.yaml import YAML
16
16
  from questionary import Choice
17
17
  from click.exceptions import Exit
18
18
 
19
- from leverage import logger
19
+ from leverage import conf, logger
20
20
  from leverage._utils import ExitError
21
21
  from leverage.modules.runner import Runner
22
22
  from leverage._internals import State, pass_runner, pass_paths, pass_state
23
23
  from leverage.path import (
24
24
  NotARepositoryError,
25
25
  PathsHandler,
26
+ build_paths_and_environment,
26
27
  get_global_config_path,
27
28
  get_project_root_or_current_dir_path,
28
29
  )
@@ -253,7 +254,6 @@ def credentials(state):
253
254
  raise an exception
254
255
 
255
256
  If we reached the only common.tfvars scenario, we have no project name nor TF_IMAGE_TAG.
256
- So the best chance is to read the common.tfvars directly without a container, e.g. with sed or grep
257
257
  """
258
258
  project_config = _load_project_yaml()
259
259
  build_env = Path(f"{PROJECT_ROOT}/build.env")
@@ -265,6 +265,20 @@ def credentials(state):
265
265
  if short_name is None or not re.match("^[a-z]{2,4}$", short_name):
266
266
  logger.error("Invalid or missing project short name in project.yaml file.")
267
267
  raise Exit(1)
268
+
269
+ if not build_env.exists():
270
+ # Completes this branch's own docstring promise, mirroring the common.tfvars branch
271
+ # below. Guarded so a mature project that still has project.yaml on disk never has
272
+ # its real build.env clobbered (project create never deletes project.yaml).
273
+ logger.info("Writing project short name to build.env.")
274
+ build_env.write_text(f"PROJECT={short_name}\nTF_IMAGE_TAG=1.1.9")
275
+
276
+ if state.paths is None:
277
+ # Upstream (leverage.py) skipped PathsHandler because only project.yaml existed.
278
+ # build.env now has a project name (or already did) - reload config from disk and
279
+ # build real, project-scoped paths here instead of leaving state.paths/environment None.
280
+ state.config = conf.load()
281
+ state.paths, state.environment = build_paths_and_environment(state.config)
268
282
  elif not build_env.exists():
269
283
  # project_config is not empty
270
284
  # and build.env does not exist
@@ -372,7 +386,7 @@ def _profile_is_configured(awscli: Runner, profile: str):
372
386
  Returns:
373
387
  bool: Whether the profile was already configured or not.
374
388
  """
375
- exit_code, _, _ = awscli.exec("configure", "list", "--profile", profile)
389
+ exit_code, _, _ = awscli.exec("configure", "list", "--profile", profile, raises=False)
376
390
 
377
391
  return not exit_code
378
392
 
@@ -446,7 +460,7 @@ def configure_credentials(
446
460
  values = {"aws_access_key_id": key_id, "aws_secret_access_key": secret_key}
447
461
 
448
462
  for key, value in values.items():
449
- exit_code, output, _ = awscli.exec("configure", "set", key, value, "--profile", profile)
463
+ exit_code, output, _ = awscli.exec("configure", "set", key, value, "--profile", profile, raises=False)
450
464
  if exit_code:
451
465
  raise ExitError(exit_code, f"AWS CLI error: {output}")
452
466
 
@@ -467,7 +481,7 @@ def _credentials_are_valid(awscli: Runner, profile: str):
467
481
  Returns:
468
482
  bool: Whether the credentials are valid.
469
483
  """
470
- error_code, output, _ = awscli.exec("sts", "get-caller-identity", "--profile", profile)
484
+ error_code, output, _ = awscli.exec("sts", "get-caller-identity", "--profile", profile, raises=False)
471
485
 
472
486
  return error_code != 255 and "InvalidClientTokenId" not in output
473
487
 
@@ -482,7 +496,9 @@ def _get_management_account_id(awscli: Runner, profile: str):
482
496
  Returns:
483
497
  str: Management account id.
484
498
  """
485
- exit_code, caller_identity, _ = awscli.exec("sts", "get-caller-identity", "--output", "json", "--profile", profile)
499
+ exit_code, caller_identity, _ = awscli.exec(
500
+ "sts", "get-caller-identity", "--output", "json", "--profile", profile, raises=False
501
+ )
486
502
  if exit_code:
487
503
  raise ExitError(exit_code, f"AWS CLI error: {caller_identity}")
488
504
 
@@ -502,7 +518,7 @@ def _get_organization_accounts(awscli: Runner, profile: str, project_name: str):
502
518
  dict: Mapping of organization accounts names to ids.
503
519
  """
504
520
  exit_code, organization_accounts, _ = awscli.exec(
505
- "organizations", "list-accounts", "--output", "json", "--profile", profile
521
+ "organizations", "list-accounts", "--output", "json", "--profile", profile, raises=False
506
522
  )
507
523
 
508
524
  if exit_code:
@@ -530,7 +546,9 @@ def _get_mfa_serial(awscli: Runner, profile: str):
530
546
  Returns:
531
547
  str: MFA device serial.
532
548
  """
533
- exit_code, mfa_devices, _ = awscli.exec("iam", "list-mfa-devices", "--output", "json", "--profile", profile)
549
+ exit_code, mfa_devices, _ = awscli.exec(
550
+ "iam", "list-mfa-devices", "--output", "json", "--profile", profile, raises=False
551
+ )
534
552
  if exit_code:
535
553
  raise ExitError(exit_code, f"AWS CLI error: {mfa_devices}")
536
554
  mfa_devices = json.loads(mfa_devices)
@@ -558,7 +576,7 @@ def configure_profile(awscli: Runner, profile: str, values: dict):
558
576
  """
559
577
  logger.info(f"\tConfiguring profile [bold]{profile}[/bold]")
560
578
  for key, value in values.items():
561
- exit_code, output, _ = awscli.exec("configure", "set", key, value, "--profile", profile)
579
+ exit_code, output, _ = awscli.exec("configure", "set", key, value, "--profile", profile, raises=False)
562
580
  if exit_code:
563
581
  raise ExitError(exit_code, f"AWS CLI error: {output}")
564
582
 
@@ -613,13 +631,78 @@ def configure_accounts_profiles(
613
631
  # A profile identifier looks like `le-security-oaar`
614
632
  account_profiles[f"{short_name}-{account_name}-{PROFILES[_type]['profile_role']}-mfa"] = account_profile
615
633
 
616
- logger.info("Backing up account profiles file.")
617
- shutil.copy(paths.aws_config_file, paths.aws_config_file.with_suffix(".bkp"))
634
+ if paths.aws_config_file.exists():
635
+ logger.info("Backing up account profiles file.")
636
+ shutil.copy(paths.aws_config_file, paths.aws_config_file.with_suffix(".bkp"))
618
637
 
619
638
  for profile_identifier, profile_values in account_profiles.items():
620
639
  configure_profile(profile_identifier, profile_values)
621
640
 
622
641
 
642
+ def _find_matching_brace(content: str, start: int):
643
+ """Find the position of the brace closing the one opened at `start`.
644
+
645
+ Braces appearing inside double quoted strings are ignored.
646
+
647
+ Args:
648
+ content (str): Text to scan.
649
+ start (int): Position of the opening brace.
650
+
651
+ Returns:
652
+ int: Position of the matching closing brace, or None if it is unbalanced.
653
+ """
654
+ depth = 0
655
+ in_string = False
656
+ position = start
657
+
658
+ while position < len(content):
659
+ char = content[position]
660
+
661
+ if in_string:
662
+ if char == "\\":
663
+ position += 1
664
+ elif char == '"':
665
+ in_string = False
666
+ elif char == '"':
667
+ in_string = True
668
+ elif char == "{":
669
+ depth += 1
670
+ elif char == "}":
671
+ depth -= 1
672
+ if not depth:
673
+ return position
674
+
675
+ position += 1
676
+
677
+ return None
678
+
679
+
680
+ def _replace_hcl_attribute(content: str, attribute: str, value: str):
681
+ """Replace the value of a brace delimited HCL attribute, honoring nested blocks.
682
+
683
+ A non greedy regex cannot be used for this: it stops at the first closing brace, which for a
684
+ nested block leaves the remaining entries orphaned and the file with unbalanced braces.
685
+
686
+ Args:
687
+ content (str): Full text of the HCL file.
688
+ attribute (str): Name of the attribute to replace, e.g. `accounts`.
689
+ value (str): New value for the attribute, enclosing braces included.
690
+
691
+ Returns:
692
+ str: Content with the attribute replaced, unchanged if the attribute was not found.
693
+ """
694
+ attribute_definition = re.search(rf"^{re.escape(attribute)}\s*=\s*\{{", content, flags=re.MULTILINE)
695
+ if attribute_definition is None:
696
+ return content
697
+
698
+ opening_brace = attribute_definition.end() - 1
699
+ closing_brace = _find_matching_brace(content, opening_brace)
700
+ if closing_brace is None:
701
+ return content
702
+
703
+ return f"{content[:attribute_definition.start()]}{attribute} = {value}{content[closing_brace + 1:]}"
704
+
705
+
623
706
  @pass_paths
624
707
  def _update_account_ids(paths: PathsHandler, config: dict):
625
708
  """Update accounts ids in global configuration file.
@@ -654,9 +737,7 @@ def _update_account_ids(paths: PathsHandler, config: dict):
654
737
  accs = f"{{{accs}\n}}"
655
738
 
656
739
  common_tfvars = paths.common_tfvars.read_text()
657
- common_tfvars = re.sub(
658
- r"accounts\s*=\s*\{.*?\}(?=\s*(?:\n|$))", f"accounts = {accs}", common_tfvars, flags=re.DOTALL
659
- )
740
+ common_tfvars = _replace_hcl_attribute(common_tfvars, "accounts", accs)
660
741
  paths.common_tfvars.write_text(common_tfvars)
661
742
 
662
743
 
@@ -23,8 +23,8 @@ REGION = r"(global|([a-z]{2}(-gov)?)-(central|(north|south)?(east|west)?)-\d)"
23
23
  @pass_state
24
24
  def tofu(state):
25
25
  """Run OpenTofu commands in the context of the current project.
26
- All tofu subcommands that receive extra args will pass the given strings as is to their corresponding OpenTofu
27
- counterparts in the container. For example as in `leverage tofu apply -auto-approve` or
26
+ All tofu subcommands that receive extra args will pass the given strings as is to the OpenTofu binary.
27
+ For example as in `leverage tofu apply -auto-approve` or
28
28
  `leverage tofu init -reconfigure`
29
29
  """
30
30
  state.runner = TFRunner(binary=state.paths.tf_binary, env_vars=state.environment)
@@ -34,8 +34,8 @@ def tofu(state):
34
34
  @pass_state
35
35
  def terraform(state):
36
36
  """Run Terraform commands in the context of the current project.
37
- All terraform subcommands that receive extra args will pass the given strings as is to their corresponding Terraform
38
- counterparts in the container. For example as in `leverage terraform apply -auto-approve` or
37
+ All terraform subcommands that receive extra args will pass the given strings as is to the Terraform binary.
38
+ For example as in `leverage terraform apply -auto-approve` or
39
39
  `leverage terraform init -reconfigure`
40
40
  """
41
41
  state.runner = TFRunner(binary=state.paths.tf_binary, terraform=True, env_vars=state.environment)
@@ -146,7 +146,8 @@ def tf_default_args(paths: PathsHandler) -> tuple:
146
146
  @pass_runner
147
147
  def version(tf):
148
148
  """Print version."""
149
- tf.run("version")
149
+ if exit_code := tf.run("version"):
150
+ raise Exit(exit_code)
150
151
 
151
152
 
152
153
  @click.command("format", context_settings=CONTEXT_SETTINGS)
@@ -155,7 +156,8 @@ def version(tf):
155
156
  def _format(tf, args):
156
157
  """Check if all files meet the canonical format and rewrite them accordingly."""
157
158
  args = args if "-recursive" in args else (*args, "-recursive")
158
- tf.run("fmt", *args)
159
+ if exit_code := tf.run("fmt", *args):
160
+ raise Exit(exit_code)
159
161
 
160
162
 
161
163
  @click.command("force-unlock")
@@ -165,7 +167,8 @@ def _format(tf, args):
165
167
  @pass_runner
166
168
  def force_unlock(tf, paths: PathsHandler, lock_id):
167
169
  """Force unlock the state file."""
168
- tf.run("force-unlock", lock_id)
170
+ if exit_code := tf.run("force-unlock", lock_id):
171
+ raise Exit(exit_code)
169
172
 
170
173
 
171
174
  @click.command()
@@ -174,7 +177,8 @@ def force_unlock(tf, paths: PathsHandler, lock_id):
174
177
  @pass_runner
175
178
  def validate(tf, paths: PathsHandler):
176
179
  """Validate code of the current directory. Previous initialization might be needed."""
177
- tf.run("validate", *tf_default_args())
180
+ if exit_code := tf.run("validate", *tf_default_args()):
181
+ raise Exit(exit_code)
178
182
 
179
183
 
180
184
  @click.command("validate-layout")
@@ -190,7 +194,8 @@ def validate_layout(paths):
190
194
  @pass_runner
191
195
  def _import(tf, address, _id):
192
196
  """Import a resource."""
193
- tf.run("import", *tf_default_args(), address, _id)
197
+ if exit_code := tf.run("import", *tf_default_args(), address, _id):
198
+ raise Exit(exit_code)
194
199
 
195
200
 
196
201
  @click.command("refresh-credentials")
@@ -275,12 +280,11 @@ def invoke_for_all_commands(paths, layers, command, *args: Sequence[str], skip_v
275
280
  backend_key = backend_key_base.replace("/base-", "/").replace("/tools-", "/")
276
281
  set_backend_key(layer / "config.tf", backend_key)
277
282
 
278
- # check layers existence
279
283
  for layer in layers:
280
284
  if len(layers) > 1:
281
285
  logger.info(f"Invoking command for layer {layer}...")
282
286
 
283
- # execute the actual command
287
+ # execute the actual command; a failing layer raises Exit and stops the remaining ones
284
288
  command(args, working_dir=layer)
285
289
 
286
290
  return layers
@@ -320,7 +324,8 @@ def _init(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir: P
320
324
  )
321
325
  init_args = (*filtered_args, f"-backend-config={paths.backend_tfvars}")
322
326
 
323
- tf.run("init", *tf_default_args(), *init_args, working_dir=working_dir)
327
+ if exit_code := tf.run("init", *tf_default_args(), *init_args, working_dir=working_dir):
328
+ raise Exit(exit_code)
324
329
 
325
330
 
326
331
  @authenticate
@@ -328,7 +333,8 @@ def _init(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir: P
328
333
  @pass_runner
329
334
  def _plan(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir: Path):
330
335
  """Generate an execution plan for this layer."""
331
- tf.run("plan", *tf_default_args(), *args, working_dir=working_dir)
336
+ if exit_code := tf.run("plan", *tf_default_args(), *args, working_dir=working_dir):
337
+ raise Exit(exit_code)
332
338
 
333
339
 
334
340
  def has_a_plan_file(args: Sequence[str]) -> bool:
@@ -393,7 +399,9 @@ def _apply(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir:
393
399
  default_args = () if has_a_plan_file(args) else tf_default_args()
394
400
  logger.debug(f"Default args passed to apply command: {default_args}")
395
401
 
396
- tf.run("apply", *default_args, *args, working_dir=working_dir)
402
+ if exit_code := tf.run("apply", *default_args, *args, working_dir=working_dir):
403
+ logger.error(f"Command execution failed with exit code: {exit_code}")
404
+ raise Exit(exit_code)
397
405
 
398
406
 
399
407
  @authenticate
@@ -401,7 +409,8 @@ def _apply(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir:
401
409
  @pass_runner
402
410
  def _output(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir: Path):
403
411
  """Show all output variables of this layer."""
404
- tf.run("output", *args, working_dir=working_dir)
412
+ if exit_code := tf.run("output", *args, working_dir=working_dir):
413
+ raise Exit(exit_code)
405
414
 
406
415
 
407
416
  @authenticate
@@ -409,7 +418,8 @@ def _output(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir:
409
418
  @pass_runner
410
419
  def _destroy(tf: TFRunner, paths: PathsHandler, args: Sequence[str], working_dir: Path):
411
420
  """Destroy infrastructure in this layer."""
412
- tf.run("destroy", *tf_default_args(), *args, working_dir=working_dir)
421
+ if exit_code := tf.run("destroy", *tf_default_args(), *args, working_dir=working_dir):
422
+ raise Exit(exit_code)
413
423
 
414
424
 
415
425
  # ###########################################################################
@@ -485,7 +495,7 @@ def _make_layer_backend_key(cwd, account_dir, account_name):
485
495
 
486
496
  @pass_paths
487
497
  def _validate_layout(paths, layer: str):
488
- paths.check_for_layer_location()
498
+ paths.check_for_layer_location(Path(layer))
489
499
 
490
500
  # Check for `environment = <account name>` in account.tfvars
491
501
  account_name = paths.account_conf.get("environment")
@@ -12,7 +12,7 @@ from leverage.modules.auth import authenticate
12
12
  @authenticate
13
13
  @pass_state
14
14
  def tfautomv(state, args):
15
- """Run TFAutomv commands in the context of the current project.`"""
15
+ """Run TFAutomv commands in the context of the current project."""
16
16
  tf_default_args_string = " ".join(tf_default_args())
17
17
  state.environment["TF_CLI_ARGS_init"] = tf_default_args_string
18
18
  state.environment["TF_CLI_ARGS_plan"] = tf_default_args_string
@@ -157,7 +157,7 @@ class PathsHandler: # TODO: Turn this class into a something that represents a
157
157
  self.backend_conf = hcl2.loads(backend_config.read_text()) if backend_config.exists() else {}
158
158
 
159
159
  # Get MFA enabled status
160
- self.mfa_enabled = env_conf.get("MFA_ENABLED", "false")
160
+ self.mfa_enabled = str(env_conf.get("MFA_ENABLED", "false")).strip().lower() == "true"
161
161
 
162
162
  # Get project name
163
163
  self.project = self.common_conf.get("project", env_conf.get("PROJECT", False))
@@ -183,11 +183,13 @@ class PathsHandler: # TODO: Turn this class into a something that represents a
183
183
 
184
184
  @property
185
185
  def common_tfvars(self):
186
- return f"{self.root_dir}/config/{self.COMMON_TF_VARS}"
186
+ # return f"{self.root_dir}/config/{self.COMMON_TF_VARS}"
187
+ return self.root_dir / "config" / self.COMMON_TF_VARS
187
188
 
188
189
  @property
189
190
  def account_tfvars(self):
190
- return f"{self.account_dir}/config/{self.ACCOUNT_TF_VARS}"
191
+ # return f"{self.account_dir}/config/{self.ACCOUNT_TF_VARS}"
192
+ return self.account_dir / "config" / self.ACCOUNT_TF_VARS
191
193
 
192
194
  @property
193
195
  def backend_tfvars(self):
@@ -267,3 +269,25 @@ def get_project_root_or_current_dir_path() -> Path:
267
269
  root = Path.cwd()
268
270
 
269
271
  return root
272
+
273
+
274
+ def is_project_yaml_only_bootstrap() -> bool:
275
+ """Whether only `project.yaml` exists yet: `project init` has run but `project create`
276
+ hasn't, so neither `build.env` nor `config/common.tfvars` exist. PathsHandler can't resolve
277
+ a project name in this state; callers should skip it and derive the name from project.yaml.
278
+ """
279
+ root = get_project_root_or_current_dir_path()
280
+ build_env = root / "build.env"
281
+ common_tfvars = Path(get_global_config_path()) / "common.tfvars"
282
+ return (root / "project.yaml").exists() and not build_env.exists() and not common_tfvars.exists()
283
+
284
+
285
+ def build_paths_and_environment(config: dict) -> tuple[PathsHandler, dict]:
286
+ """Build a PathsHandler and the AWS cli environment variables derived from it. Shared by any
287
+ group callback that needs to resolve project paths from `state.config`."""
288
+ paths = PathsHandler(config)
289
+ environment = {
290
+ "AWS_SHARED_CREDENTIALS_FILE": str(paths.aws_credentials_file),
291
+ "AWS_CONFIG_FILE": str(paths.aws_config_file),
292
+ }
293
+ return paths, environment
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "leverage"
3
- version = "3.1.0"
3
+ version = "3.2.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