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

binbashar / leverage / 15054008083

15 May 2025 07:55PM UTC coverage: 61.138% (+0.2%) from 60.982%
15054008083

Pull #305

github

angelofenoglio
Fix kubectl discovery issue
Pull Request #305: BL-255 | Add `tofu` command to cli

210 of 476 branches covered (44.12%)

Branch coverage included in aggregate %.

48 of 57 new or added lines in 8 files covered. (84.21%)

41 existing lines in 2 files now uncovered.

2606 of 4130 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())
×
206
        if not "terraform_image_tag" in config:
×
207
            config["terraform_image_tag"] = __toolbox_version__
×
208
        rendered_template = template.render(config)
×
209

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

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

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

225
        rendered_location.write_text(rendered_template)
×
226

227

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

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

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

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

244

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

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

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

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

260

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

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

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

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

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

282

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

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

299
    return True
1✔
300

301

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

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

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

318
    validate_config(config)
×
319

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

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

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

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

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

337

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

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

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

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

359
    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

© 2026 Coveralls, Inc