projectsyn/commodore

View on GitHub
commodore/component/compile.py

Summary

Maintainability
A
0 mins
Test Coverage
A
93%
from __future__ import annotations

import shutil
import tempfile

from collections.abc import Iterable
from pathlib import Path as P
from textwrap import dedent
from typing import Optional

import click
import git
from kapitan.resources import inventory_reclass

from commodore.cluster import generate_target
from commodore.config import Config
from commodore.component import Component
from commodore.dependency_mgmt.component_library import (
    validate_component_library_name,
    create_component_library_aliases,
)
from commodore.dependency_mgmt.jsonnet_bundler import fetch_jsonnet_libraries
from commodore.helpers import kapitan_compile, relsymlink, yaml_dump
from commodore.inventory import Inventory
from commodore.inventory.lint import check_removed_reclass_variables
from commodore.postprocess import postprocess_components


# pylint: disable=too-many-arguments disable=too-many-locals
def compile_component(
    config: Config,
    component_path_: str,
    instance_name_: Optional[str],
    value_files_: Iterable[str],
    search_paths_: Iterable[str],
    output_path_: str,
    component_name: str,
):
    # Resolve all input to absolute paths to fix symlinks
    component_path = P(component_path_).resolve()
    value_files = [P(f).resolve() for f in value_files_]
    search_paths = [P(d).resolve() for d in search_paths_]
    search_paths.append(component_path / "vendor")
    output_path = P(output_path_).resolve()

    if not component_name:
        # Ignore 'component-' prefix in repo name, this assumes that the repo name
        # indicates the component name for components in subpaths.
        component_name = component_path.stem.replace("component-", "")

    # Fall back to `component as component` when instance_name is empty
    if instance_name_ is None or instance_name_ == "":
        instance_name = component_name
        click.secho(f"Compile component {component_name}...", bold=True)
    else:
        instance_name = instance_name_
        click.secho(
            f"Compile component {component_name} as {instance_name}...", bold=True
        )

    temp_dir = P(tempfile.mkdtemp(prefix="component-")).resolve()
    config.work_dir = temp_dir
    try:
        if config.debug:
            click.echo(f"   > Created temp workspace: {config.work_dir}")
        inv = config.inventory
        inv.ensure_dirs()
        search_paths.append(inv.dependencies_dir)
        component = _setup_component(
            config,
            component_name,
            instance_name,
            component_path,
        )
        _prepare_kapitan_inventory(inv, component, value_files, instance_name)

        # Raise error if component uses removed reclass parameters
        check_removed_reclass_variables(
            config,
            "component",
            [component.defaults_file, component.class_file] + value_files,
        )

        # Verify component alias
        nodes = inventory_reclass(inv.inventory_dir)["nodes"]
        config.verify_component_aliases(nodes[instance_name]["parameters"])

        cluster_params = nodes[instance_name]["parameters"]
        create_component_library_aliases(config, cluster_params)

        # Render jsonnetfile.jsonnet if necessary
        component_params = nodes[instance_name]["parameters"].get(
            component_name.replace("-", "_"), {}
        )
        component.render_jsonnetfile_json(component_params)
        # Fetch Jsonnet libs
        fetch_jsonnet_libraries(component_path)

        # Compile component
        kapitan_compile(
            config,
            [instance_name],
            output_dir=output_path,
            search_paths=search_paths,
            fake_refs=True,
            reveal=True,
        )
        click.echo(
            f" > Component compiled to {output_path / 'compiled' / instance_name}"
        )

        # Change working directory for postprocessing
        config.work_dir = output_path
        postprocess_components(config, nodes, config.get_components())
        config.print_deprecation_notices()
    finally:
        if config.trace:
            click.echo(f" > Temp dir left in place {temp_dir}")
        else:
            if config.debug:
                click.echo(f" > Remove temp dir {temp_dir}")
            shutil.rmtree(temp_dir)


def _setup_component(
    config: Config,
    component_name: str,
    instance_name: str,
    component_path: P,
) -> Component:
    if not component_path.is_dir():
        raise click.ClickException(
            f"Can't compile component, repository {component_path} doesn't exist"
        )
    # Search for git repo in parents, this is necessary when compiling a component in a
    # repo subdirectory.
    try:
        cr = git.Repo(component_path, search_parent_directories=True)
        if not cr.working_tree_dir:
            raise click.ClickException(
                f"Can't compile component, repository {component_path} doesn't exist"
            )
        target_dir = P(cr.working_tree_dir)
        # compute subpath from Repo working tree dir and component path
        sub_path = str(component_path.absolute().relative_to(target_dir))
    except git.InvalidGitRepositoryError:
        click.echo(" > Couldn't determine Git repository for component")
        # Just treat `component_path` as a directory holding a component, don't care
        # about Git repo details here.
        target_dir = component_path
        sub_path = ""

    component = Component(
        component_name,
        None,
        # Use repo working tree as component "target directory", otherwise we get messy
        # results with duplicate subpaths.
        directory=target_dir,
        # Use computed subpath to ensure we accurately replicate the environment in
        # which the component will be compiled in a cluster catalog.
        sub_path=sub_path,
    )
    config.register_component(component)
    config.register_component_aliases({instance_name: component_name})

    # Validate component libraries
    for lib in component.lib_files:
        validate_component_library_name(component.name, lib)

    return component


def _prepare_kapitan_inventory(
    inv: Inventory, component: Component, value_files: Iterable[P], instance_name: str
):
    """
    Setup Kapitan inventory.

    Create component symlinks, values file symlinks, setup params class with fake values
    and Kapitan target for the component, create a fake `lib/argocd.libjsonnet`.
    """
    component_class_file = component.class_file
    component_defaults_file = component.defaults_file
    if not component_class_file.exists():
        raise click.ClickException(
            f"Could not find component class file: {component_class_file}"
        )
    if not component_defaults_file.exists():
        raise click.ClickException(
            f"Could not find component default file: {component_defaults_file}"
        )

    # Create class symlink
    relsymlink(component_class_file, inv.components_dir)
    # Create defaults symlink
    relsymlink(
        component_defaults_file,
        inv.defaults_dir,
        dest_name=f"{component.name}.yml",
    )
    # Create component symlink
    relsymlink(component.target_directory, inv.dependencies_dir, component.name)
    # Create value symlinks
    for file in value_files:
        relsymlink(file.parent / file.name, inv.classes_dir)

    # Create class for fake parameters
    yaml_dump(
        {
            "parameters": {
                "cluster": {
                    "catalog_url": "ssh://git@git.example.com/org/repo.git",
                    "name": "c-green-test-1234",
                    "display_name": "Test Cluster 1234",
                    "tenant": "t-silent-test-1234",
                    "tenant_display_name": "Test Tenant 1234",
                },
                "facts": {
                    "distribution": "test-distribution",
                    "cloud": "cloudscale",
                    "region": "rma1",
                },
                "argocd": {
                    "namespace": "test",
                },
                "components": {
                    component.name: {
                        "url": f"https://example.com/{component.name}.git",
                        "version": "master",
                    }
                },
                "kapitan": {
                    "vars": {
                        "target": instance_name,
                    },
                    "namespace": "test",
                },
            }
        },
        inv.params_file,
    )

    # Create test target
    value_classes = [f"{c.stem}" for c in value_files]
    classes = [
        f"params.{inv.bootstrap_target}",
        f"defaults.{component.name}",
        f"components.{component.name}",
    ] + value_classes
    yaml_dump(
        generate_target(
            inv, instance_name, {component.name: component}, classes, component.name
        ),
        inv.target_file(instance_name),
    )

    # Fake Argo CD lib
    # We plug "fake" Argo CD library here because every component relies on it
    # and we don't want to provide it every time when compiling a single component.
    with open(inv.lib_dir / "argocd.libjsonnet", "w", encoding="utf-8") as argocd_libf:
        argocd_libf.write(
            dedent(
                """
            local ArgoApp(component, namespace, project='', secrets=true) = {};
            local ArgoProject(name) = {};

            {
              App: ArgoApp,
              Project: ArgoProject,
            }"""
            )
        )