• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

binbashar / leverage / 17239897634

26 Aug 2025 05:00AM UTC coverage: 61.145%. Remained the same
17239897634

push

github

web-flow
Fix | Remove reference to Terraform in error message (#312)

* Change error message for commands out of place

* Grammar

* Update lock file

* Fix integration test workflows

* Drop tofu image for now

* Small improvement in README

* Fix lock file

* Explicitly call terraform command

* Change error message for commands out of place

* Grammar

* Remove or adapt references to terraform

* Format

* Revert name of variable to render

* Pythonicism

210 of 476 branches covered (44.12%)

Branch coverage included in aggregate %.

1 of 4 new or added lines in 3 files covered. (25.0%)

32 existing lines in 3 files now uncovered.

2610 of 4136 relevant lines covered (63.1%)

0.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

31.21
/leverage/modules/project.py
1
"""
2
    Module for managing Leverage projects.
3
"""
4

5
import re
1✔
6
from pathlib import Path
1✔
7
from shutil import copy2
1✔
8
from shutil import copytree
1✔
9
from shutil import ignore_patterns
1✔
10

11
import click
1✔
12
from click.exceptions import Exit
1✔
13
from ruamel.yaml import YAML
1✔
14
from jinja2 import Environment
1✔
15
from jinja2 import FileSystemLoader
1✔
16

17
from leverage import __toolbox_version__
1✔
18
from leverage import logger
1✔
19
from leverage.logger import console
1✔
20
from leverage.path import get_root_path, get_project_root_or_current_dir_path
1✔
21
from leverage.path import NotARepositoryError
1✔
22
from leverage._utils import git, ExitError
1✔
23
from leverage.container import get_docker_client
1✔
24
from leverage.container import TFContainer
1✔
25

26
# Leverage related base definitions
27
LEVERAGE_DIR = Path.home() / ".leverage"
1✔
28
TEMPLATES_REPO_DIR = LEVERAGE_DIR / "templates"
1✔
29
TEMPLATE_DIR = TEMPLATES_REPO_DIR / "template"
1✔
30
PROJECT_CONFIG_FILE = "project.yaml"
1✔
31
TEMPLATE_PATTERN = "*.template"
1✔
32
CONFIG_FILE_TEMPLATE = TEMPLATES_REPO_DIR / "le-resources" / PROJECT_CONFIG_FILE
1✔
33
LEVERAGE_TEMPLATE_REPO = "https://github.com/binbashar/le-tf-infra-aws-template.git"
1✔
34
IGNORE_PATTERNS = ignore_patterns(TEMPLATE_PATTERN, ".gitkeep")
1✔
35

36
# Useful project related definitions
37
PROJECT_ROOT = get_project_root_or_current_dir_path()
1✔
38
PROJECT_CONFIG = PROJECT_ROOT / PROJECT_CONFIG_FILE
1✔
39

40
CONFIG_DIRECTORY = "config"
1✔
41

42
# TODO: Keep this structure in the project's directory
43
PROJECT_STRUCTURE = {
1✔
44
    "management": {
45
        "global": [
46
            "organizations",
47
            "sso",
48
        ],
49
        "primary_region": [
50
            "base-tf-backend",
51
            "security-base",
52
        ],
53
    },
54
    "security": {
55
        "global": [],
56
        "primary_region": [
57
            "base-tf-backend",
58
            "security-base",
59
        ],
60
    },
61
    "shared": {
62
        "global": [],
63
        "primary_region": [
64
            "base-network",
65
            "base-tf-backend",
66
            "security-base",
67
        ],
68
    },
69
}
70

71

72
@click.group()
1✔
73
def project():
1✔
74
    """Manage a Leverage project."""
75

76

77
@project.command()
1✔
78
def init():
1✔
79
    """Initializes and gets all the required resources to be able to create a new Leverage project."""
80

81
    # Application's directory
82
    if not LEVERAGE_DIR.exists():
×
83
        logger.info("No [bold]Leverage[/bold] config directory found in user's home. Creating.")
×
84
        LEVERAGE_DIR.mkdir()
×
85

86
    # Leverage project templates
87
    if not TEMPLATES_REPO_DIR.exists():
×
88
        TEMPLATES_REPO_DIR.mkdir(parents=True)
×
89

90
    if not (TEMPLATES_REPO_DIR / ".git").exists():
×
91
        logger.info("No project template found. Cloning template.")
×
92
        git(f"clone {LEVERAGE_TEMPLATE_REPO} {TEMPLATES_REPO_DIR.as_posix()}")
×
93
        logger.info("Finished cloning template.")
×
94

95
    else:
96
        logger.info("Project template found. Updating.")
×
97
        git(f"-C {TEMPLATES_REPO_DIR.as_posix()} checkout master")
×
98
        git(f"-C {TEMPLATES_REPO_DIR.as_posix()} pull")
×
99
        logger.info("Finished updating template.")
×
100

101
    # Leverage projects are git repositories too
102
    logger.info("Initializing git repository in project directory.")
×
103
    git("init")
×
104

105
    # Project configuration file
106
    if not PROJECT_CONFIG.exists():
×
107
        logger.info(
×
108
            f"No project configuration file found. Dropping configuration template [bold]{PROJECT_CONFIG_FILE}[/bold]."
109
        )
110
        copy2(src=CONFIG_FILE_TEMPLATE, dst=PROJECT_CONFIG_FILE)
×
111

112
    else:
113
        logger.warning(f"Project configuration file [bold]{PROJECT_CONFIG_FILE}[/bold] already exists in directory.")
×
114

115
    logger.info("Project initialization finished.")
×
116

117

118
def _copy_account(account, primary_region):
1✔
119
    """Copy account directory and all its files.
120

121
    Args:
122
        account (str): Account name.
123
        primary_region (str): Projects primary region.
124
    """
125
    (PROJECT_ROOT / account).mkdir()
×
126

127
    # Copy config directory
128
    copytree(
×
129
        src=TEMPLATE_DIR / account / CONFIG_DIRECTORY,
130
        dst=PROJECT_ROOT / account / CONFIG_DIRECTORY,
131
        ignore=IGNORE_PATTERNS,
132
    )
133
    # Copy all global layers in account
134
    for layer in PROJECT_STRUCTURE[account]["global"]:
×
135
        copytree(
×
136
            src=TEMPLATE_DIR / account / "global" / layer,
137
            dst=PROJECT_ROOT / account / "global" / layer,
138
            ignore=IGNORE_PATTERNS,
139
            symlinks=True,
140
        )
141
    # Copy all layers with a region in account
142
    for layer in PROJECT_STRUCTURE[account]["primary_region"]:
×
143
        copytree(
×
144
            src=TEMPLATE_DIR / account / "primary_region" / layer,
145
            dst=PROJECT_ROOT / account / primary_region / layer,
146
            ignore=IGNORE_PATTERNS,
147
            symlinks=True,
148
        )
149

150

151
def _copy_project_template(config):
1✔
152
    """Copy all files and directories from the Leverage project template to the project directory.
153
    It excludes al jinja templates as those will be rendered directly to their final location.
154

155
    Args:
156
        config (dict): Project configuration.
157
    """
158
    logger.info("Creating project directory structure.")
×
159

160
    # Copy .gitignore file
161
    copy2(src=TEMPLATE_DIR / ".gitignore", dst=PROJECT_ROOT / ".gitignore")
×
162

163
    # Root config directory
164
    copytree(src=TEMPLATE_DIR / CONFIG_DIRECTORY, dst=PROJECT_ROOT / CONFIG_DIRECTORY, ignore=IGNORE_PATTERNS)
×
165

166
    # Accounts
167
    for account in PROJECT_STRUCTURE:
×
168
        _copy_account(account=account, primary_region=config["primary_region"])
×
169

170
    logger.info("Finished creating directory structure.")
×
171

172

173
def value(dictionary, key):
1✔
174
    """Utility function to be used as jinja filter, to ease extraction of values from dictionaries,
175
    which is sometimes necessary.
176

177
    Args:
178
        dictionary (dict): The dictionary from which a value is to be extracted
179
        key (str): Key corresponding to the value to be extracted
180

181
    Returns:
182
        any: The value stored in the key
183
    """
184
    return dictionary[key]
×
185

186

187
# Jinja environment used for rendering the templates
188
JINJA_ENV = Environment(loader=FileSystemLoader(TEMPLATES_REPO_DIR.as_posix()), trim_blocks=False, lstrip_blocks=False)
1✔
189
JINJA_ENV.filters["value"] = value
1✔
190

191

192
def _render_templates(template_files, config, source=TEMPLATE_DIR, destination=PROJECT_ROOT):
1✔
193
    """Render the given templates using the given configuration values.
194

195
    Args:
196
        template_files (iterable(Path)): Iterable containing the Path objects corresponding to the
197
            templates to render.
198
        config (dict): Values to replace in the templates.
199
        source (Path, optional): Source directory of the templates. Defaults to TEMPLATE_DIR.
200
        destination (Path, optional): Destination where to render the templates. Defaults to PROJECT_ROOT.
201
    """
202
    for template_file in template_files:
×
203
        template_location = template_file.relative_to(TEMPLATES_REPO_DIR)
×
204

205
        template = JINJA_ENV.get_template(template_location.as_posix())
×
NEW
206
        if "terraform_image_tag" not in config:
×
207
            config["terraform_image_tag"] = __toolbox_version__
×
208

UNCOV
209
        rendered_template = template.render(config)
×
210

211
        rendered_location = template_file.relative_to(source)
×
212
        if (
×
213
            rendered_location.parent.name == ""
214
            or rendered_location.parent.name == CONFIG_DIRECTORY
215
            or rendered_location.parent.parent.name == "global"
216
        ):
217
            rendered_location = destination / rendered_location
×
218

219
        else:
220
            region_name = template_location.parent.parent.name
×
221
            rendered_location = rendered_location.as_posix().replace(region_name, config[region_name])
×
222
            rendered_location = destination / Path(rendered_location)
×
223

224
        rendered_location = rendered_location.with_suffix("")
×
225

226
        rendered_location.write_text(rendered_template)
×
227

228

229
def _render_account_templates(account, config, source=TEMPLATE_DIR):
1✔
230
    account_name = account["name"]
×
231
    logger.info(f"Account: Setting up [bold]{account_name}[/bold].")
×
232
    account_dir = source / account_name
×
233

234
    layers = [CONFIG_DIRECTORY]
×
235
    for account_name, account_layers in PROJECT_STRUCTURE[account_name].items():
×
236
        layers = layers + [f"{account_name}/{layer}" for layer in account_layers]
×
237

238
    for layer in layers:
×
239
        logger.info(f"\tLayer: Setting up [bold]{layer.split('/')[-1]}[/bold].")
×
240
        layer_dir = account_dir / layer
×
241

242
        layer_templates = layer_dir.glob(TEMPLATE_PATTERN)
×
243
        _render_templates(template_files=layer_templates, config=config, source=source)
×
244

245

246
def _render_project_template(config, source=TEMPLATE_DIR):
1✔
247
    # Render base and non account related templates
248
    template_files = list(source.glob(TEMPLATE_PATTERN))
×
249
    config_templates = list((source / CONFIG_DIRECTORY).rglob(TEMPLATE_PATTERN))
×
250
    template_files.extend(config_templates)
×
251

252
    logger.info("Setting up common base files.")
×
253
    _render_templates(template_files=template_files, config=config, source=source)
×
254

255
    # Render each account's templates
256
    for account in config["organization"]["accounts"]:
×
257
        _render_account_templates(account=account, config=config, source=source)
×
258

259
    logger.info("Project configuration finished.")
×
260

261

262
def load_project_config():
1✔
263
    """Load project configuration file.
264

265
    Raises:
266
        Exit: For any error produced during configuration loading.
267

268
    Returns:
269
        dict:  Project configuration.
270
    """
271
    if not PROJECT_CONFIG.exists():
×
272
        logger.debug("No project config file found.")
×
273
        return {}
×
274

275
    try:
×
276
        return YAML().load(PROJECT_CONFIG)
×
277

278
    except Exception as exc:
×
279
        exc.__traceback__ = None
×
280
        logger.exception(message="Error loading configuration file.", exc_info=exc)
×
281
        raise Exit(1)
×
282

283

284
def validate_config(config: dict):
1✔
285
    """
286
    Run a battery of validations over the config file (project.yaml).
287
    """
288
    if not re.match(r"^[a-z0-9]([a-z0-9]|-){1,23}[a-z0-9]$", config["project_name"]):
1✔
289
        raise ExitError(
1✔
290
            1,
291
            "Project name is not valid. Only lowercase alphanumeric characters and hyphens are allowed. It must be 25 characters long at most.",
292
        )
293

294
    if not re.match(r"^[a-z]{2,4}$", config["short_name"]):
1✔
295
        raise ExitError(
1✔
296
            1,
297
            "Project short name is not valid. Only lowercase alphabetic characters are allowed. It must be between 2 and 4 characters long.",
298
        )
299

300
    return True
1✔
301

302

303
@project.command()
1✔
304
def create():
1✔
305
    """Create the directory structure required by the project configuration and set up each account accordingly."""
306

307
    config = load_project_config()
×
308
    if not config:
×
309
        logger.error(
×
310
            "No configuration file found for the project."
311
            " Make sure the project has already been initialized ([bold]leverage project init[/bold])."
312
        )
313
        return
×
314

315
    if (PROJECT_ROOT / "config").exists():
×
316
        logger.error("Project has already been created.")
×
317
        return
×
318

319
    validate_config(config)
×
320

321
    # Make project structure
322
    _copy_project_template(config=config)
×
323

324
    # Render project
325
    _render_project_template(config=config)
×
326

327
    # Format the code correctly
NEW
328
    logger.info("Reformatting configuration to the standard style.")
×
329

330
    terraform = TFContainer(get_docker_client())
×
331
    terraform.ensure_image()
×
332
    terraform.disable_authentication()
×
333
    with console.status("Formatting..."):
×
334
        terraform.exec("fmt", "-recursive")
×
335

336
    logger.info("Finished setting up project.")
×
337

338

339
def render_file(file, config=None):
1✔
340
    """Utility to re-render specific files.
341

342
    Args:
343
        file (str): Relative path to file to render.
344
        config (dict, optional): Config used to render file.
345

346
    Returns:
347
        bool: Whether the action succeeded or not
348
    """
349
    if not config:
×
350
        # TODO: Make use of internal state
351
        config = load_project_config()
×
352
        if not config:
×
353
            return False
×
354

355
    try:
×
356
        _render_templates([TEMPLATE_DIR / f"{file}.template"], config=config)
×
357
    except FileNotFoundError:
×
358
        return False
×
359

360
    return True
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc