Commit 1bd141bc authored by Pierre Smeyers's avatar Pierre Smeyers
Browse files

Merge branch 'feature/enhancements' into 'main'

new rules implemented

Closes #2, #3, #4, #5, and #6

See merge request to-be-continuous/tools/tbc-check!6
parents 50ba3a2f 79ecf942
Loading
Loading
Loading
Loading
+4 −1
Original line number Diff line number Diff line
@@ -11,10 +11,13 @@ include:
      prod-publish-strategy: "auto"
      release-extra-tags: "latest \\g<major>.\\g<minor>\\g<build> \\g<major>\\g<build>"
  # Python template
  - component: "gitlab.com/$TBC_NAMESPACE/python/gitlab-ci-python@6.7"
  - component: "gitlab.com/$TBC_NAMESPACE/python/gitlab-ci-python@6.10"
    inputs:
      image: "registry.hub.docker.com/library/python:3.12"
      ruff-enabled: true
      pytest-enabled: true
      package-enabled: true
      release-enabled: true
      sbom-disabled: true
  # semantic-release template
  - component: "gitlab.com/to-be-continuous/semantic-release/gitlab-ci-semrel@3.8"

.releaserc.yaml

0 → 100644
+27 −0
Original line number Diff line number Diff line
plugins:
  - '@semantic-release/commit-analyzer'
  - '@semantic-release/release-notes-generator'
  - '@semantic-release/gitlab'
  # generates the CHANGELOG.md
  - '@semantic-release/changelog'
  # emulates bumpversion (replaces version in pyproject.toml)
  - - semantic-release-replace-plugin
    - replacements:
        - files:
            - pyproject.toml
          from:
            - ^version *= *"\d+\.\d+\.\d+"
          to: 'version = "${nextRelease.version}"'
          countMatches: true
  # git commit/push modified files (CHANGELOG.md & pyproject.toml)
  - - '@semantic-release/git'
    - assets:
        - '*.md'
        - '*.toml'
      # the commit MUST trigger a pipeline on tag (to perform publish jobs)
      # can be skipped on prod branch
      message: 'chore(semantic-release): release ${nextRelease.version} - [ci skip on prod]'
branches:
  - main
  - master
tagFormat: '${version}'
+109 −382

File changed.

Preview size limit exceeded, changes collapsed.

+18 −8
Original line number Diff line number Diff line
@@ -25,8 +25,7 @@ pytest = "^7.0.0"
pytest-cov = "^4.0.0"
pytest-env = "^0.8.0"
mypy = "^1.0.0"
pylint = "^2.17.5"
black = "^24.1.1"
ruff = "^0.4.1"

[tool.pytest.ini_options]
# this project uses "pytest" for unit testing
@@ -35,12 +34,23 @@ testpaths = [
    "tests",
]

[tool.black]
# this project uses "Black" as code formatter
# see: https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#usage
line-length = 88
target-version = ['py312']
include = '\.pyi?$'
[tool.ruff.lint]
extend-select = ["I"] # isort
ignore = ["F841"]

[tool.ruff.format]
# Like Black, use double quotes for strings.
quote-style = "double"

# Like Black, indent with spaces, rather than tabs.
indent-style = "space"

# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false

# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"


[tool.mypy]
# this project uses "mypy" as a static type checker
+234 −56
Original line number Diff line number Diff line
@@ -4,7 +4,7 @@ import re
from enum import Enum
from logging import Logger
from pathlib import Path
from typing import Optional, Union
from typing import Any, Optional, Union

import yaml
from pydantic import BaseModel
@@ -62,22 +62,37 @@ class TbcVarType(str, Enum):


class GlInput(BaseModel):
    # name: str
    """GitLab input from the 'specs' section."""

    description: Optional[str] = None
    """Input description."""
    type: GlInputType = GlInputType.string
    """Input type."""
    options: Optional[list[str]] = None
    """Input options (allowed values)."""
    default: Optional[Union[str, bool, int]] = None
    """Input default."""


class TbcVar(BaseModel):
    """to-be-continuous variable from the Kicker declaration."""

    name: str
    """Variable name."""
    description: Optional[str] = None
    """Variable name."""
    type: TbcVarType = TbcVarType.text
    """Variable type."""
    values: Optional[list[str]] = None
    """Variable allowed values (for an enumerated type)."""
    default: Optional[str] = None
    """Variable default value."""
    advanced: bool = False
    """Whether the variable is advanced."""
    secret: bool = False
    """Whether the variable is a secret."""
    mandatory: bool = False
    """Whether the variable is mandatory."""

    def input_name(self, var_prefix: str) -> str:
        return (
@@ -111,13 +126,21 @@ class TbcVar(BaseModel):


class DocVar(BaseModel):
    """Variable delcaration from the documentation (README)."""

    lock: bool
    """Whether the variable show a lock icon (expected for secrets)."""
    var_name: str
    """The variable name."""
    input_name: Optional[str] = None
    """The associated input name."""
    description: str
    """The textual variable description."""
    default_cell: str
    """The textual content of the default value column."""

    def default(self, type: GlInputType) -> Optional[str]:
        """Extracts and converts the variable default value from the cell content."""
        code_expr_match = re.match(r"`([^`]*)`", self.default_cell)
        explicit_default = code_expr_match.group(1) if code_expr_match else None

@@ -130,12 +153,12 @@ class DocVar(BaseModel):
        return explicit_default or ""


def _get_var(tpl_desc: dict[str, any], var_name: str) -> Optional[TbcVar]:
def _get_var(kicker: dict[str, any], var_name: str) -> Optional[TbcVar]:
    var = next(
        iter(
            [
                TbcVar.parse_obj(var)
                for var in tpl_desc.get("variables", [])
                TbcVar.model_validate(var)
                for var in kicker.get("variables", [])
                if var["name"] == var_name
            ]
        ),
@@ -145,7 +168,7 @@ def _get_var(tpl_desc: dict[str, any], var_name: str) -> Optional[TbcVar]:
        return var

    # look into feature variables
    for feat in tpl_desc.get("features", []):
    for feat in kicker.get("features", []):
        if feat.get("enable_with") == var_name:
            return TbcVar(
                name=feat.get("enable_with"),
@@ -161,7 +184,7 @@ def _get_var(tpl_desc: dict[str, any], var_name: str) -> Optional[TbcVar]:
        var = next(
            iter(
                [
                    TbcVar.parse_obj(var)
                    TbcVar.model_validate(var)
                    for var in feat.get("variables", [])
                    if var["name"] == var_name
                ]
@@ -178,21 +201,56 @@ def _check_var(
    tbc_var: TbcVar,
    var_prefix: str,
    tpl_spec: dict[str, any],
    tpl_impl: dict[str, any],
    main_tpl_desc: Optional[dict[str, any]],
    tpl_body: dict[str, any],
    root_kicker: Optional[dict[str, any]],
    doc_vars: list[DocVar],
) -> int:
    """Check variable rules."""
    has_no_input = (
        tbc_var.secret
        or main_tpl_desc
        and _get_var(main_tpl_desc, tbc_var.name)
        or root_kicker
        and _get_var(root_kicker, tbc_var.name)
        or tbc_var.name.startswith("TBC_")
    )
    expected_input_name = tbc_var.input_name(var_prefix)
    expected_gl_input = tbc_var.to_gl()
    # check variable is declared in doc

    err_count = 0

    # check variable declaration from Kicker
    # --------------------------------------
    # check: empty default can be omitted in kicker.json
    if tbc_var.default == "":
        print(
            f"  {AnsiColors.RED}✕ <{tbc_var.name}>: empty default shall be omitted in Kicker{AnsiColors.RESET}"
        )
        err_count += 1

    # check container image rules
    if (
        tbc_var.name.endswith("_IMAGE")
        and tbc_var.default
        and not tbc_var.default.startswith("$")
    ):
        # check: explicit image registry
        img_parts = tbc_var.default.split("/")
        if img_parts[0].find(".") < 0:
            print(
                f"  {AnsiColors.RED}✕ <{tbc_var.name}>: container images must explicitly specify the registry{AnsiColors.RESET}"
            )
            err_count += 1

        # check: latest tag by default
        tag = img_parts[-1].split(":")[-1] if img_parts[-1].find(":") > 0 else "latest"
        if tag != "latest":
            print(
                f"  {AnsiColors.YELLOW}⚠ <{tbc_var.name}/{expected_input_name}>: container images should use 'latest' tag by default ('{tag}' found){AnsiColors.RESET}"
            )

    # check variable declaration from doc (warn only)
    # -----------------------------------
    doc_var = next(filter(lambda dv: dv.var_name == tbc_var.name, doc_vars), None)
    if doc_var == None:
    if doc_var is None:
        print(
            f"  {AnsiColors.YELLOW}⚠ <{tbc_var.name}>: not documented in README{AnsiColors.RESET}"
        )
@@ -200,7 +258,7 @@ def _check_var(
        # check default
        if doc_var.default(expected_gl_input.type) != expected_gl_input.default:
            print(
                f"  {AnsiColors.YELLOW}⚠ <{tbc_var.name}/{expected_input_name}>: README default ({doc_var.default(tbc_var.type)}) doesn't match Kicker's ({expected_gl_input.default}){AnsiColors.YELLOW}"
                f"  {AnsiColors.YELLOW}⚠ <{tbc_var.name}/{expected_input_name}>: README default ({doc_var.default(tbc_var.type)}) doesn't match Kicker's ({expected_gl_input.default}){AnsiColors.RESET}"
            )

        if doc_var.lock and not tbc_var.secret:
@@ -219,53 +277,52 @@ def _check_var(
    # retrieve declared input from template specs
    declared_input = tpl_spec["spec"]["inputs"].get(expected_input_name)

    # check cases where variable must not be declared as an input
    # -----------------------------------------------------------
    if tbc_var.secret:
        # secrets should not be inputs
        if declared_input:
            print(
                f"  {AnsiColors.RED}✕ <{tbc_var.name}> is a secret: must not be declared{AnsiColors.RESET}"
            )
            return 1
        else:
            return err_count + 1
        print(
            f"  {AnsiColors.HGRAY}✕ <{tbc_var.name}> is a secret: skip{AnsiColors.RESET}"
        )
            return 0
    if main_tpl_desc and _get_var(main_tpl_desc, tbc_var.name):
        return err_count
    if root_kicker and _get_var(root_kicker, tbc_var.name):
        # a variant is overriding a variable from the main template: skip
        if declared_input:
            print(
                f"  {AnsiColors.RED}✕ <{tbc_var.name}> is an override: must not be declared{AnsiColors.RESET}"
            )
            return 1
        else:
            return err_count + 1
        print(
            f"  {AnsiColors.HGRAY}✕ <{tbc_var.name}> is an override: skip{AnsiColors.RESET}"
        )
            return 0
        return err_count
    if tbc_var.name.startswith("TBC_"):
        # global TBC variable: skip
        if declared_input:
            print(
                f"  {AnsiColors.RED}✕ <{tbc_var.name}> is global TBC: must not be declared{AnsiColors.RESET}"
            )
            return 1
        else:
            return err_count + 1
        print(
            f"  {AnsiColors.HGRAY}✕ <{tbc_var.name}> is global TBC: skip{AnsiColors.RESET}"
        )
            return 0
        return err_count

    # check if mapped GitLab input is declared
    if not declared_input:
        print(
            f"  {AnsiColors.RED}✕ <{tbc_var.name}/{expected_input_name}>: input not found{AnsiColors.RESET}"
        )
        return 1
        return err_count + 1

    err_count = 0
    # check actual input is as expected
    actual_gl_input = GlInput.parse_obj(declared_input)
    # check GitLab input is as expected
    # ---------------------------------
    actual_gl_input = GlInput.model_validate(declared_input)

    if actual_gl_input.type != expected_gl_input.type:
        print(
@@ -291,7 +348,8 @@ def _check_var(
        err_count += 1

    # check variable is initialized from input
    actual_variable_value = tpl_impl["variables"].get(tbc_var.name)
    # ----------------------------------------
    actual_variable_value = tpl_body["variables"].get(tbc_var.name)
    expected_variable_value = f"$[[ inputs.{expected_input_name} ]]"
    if not actual_variable_value:
        print(
@@ -304,6 +362,7 @@ def _check_var(
        )
        err_count += 1

    # print an OK message if no error was found
    if err_count == 0:
        print(
            f"  {AnsiColors.GREEN}{AnsiColors.RESET} <{tbc_var.name}/{expected_input_name}>: OK"
@@ -312,34 +371,151 @@ def _check_var(
    return err_count


report_types_constraints = {
    "junit": {"formats": ["junit", "xunit"], "extensions": ["xml"]},
    "coverage_report": {"formats": ["cobertura"], "extensions": ["xml"]},
    "cyclonedx": {"formats": ["cyclonedx"], "extensions": ["json"]},
    "codequality": {"formats": ["codeclimate", "gitlab"], "extensions": ["json"]},
    "container_scanning": {"formats": ["gitlab"], "extensions": ["json"]},
    "load_performance": {"formats": ["summary"], "extensions": ["json"]},
}


def _check_report(
    job_name: str,
    type: str,
    path: str,
) -> int:
    """Check a report file."""
    if type in ["dotenv"]:
        # ignore
        return 0

    err_count = 0
    constraints = report_types_constraints.get(type)
    expected_formats = constraints["formats"] if constraints else None
    expected_exts = constraints["extensions"] if constraints else None
    if constraints is None:
        print(
            f"  {AnsiColors.HGRAY}? report '{path}' ({type}): unsupported report type{AnsiColors.RESET}"
        )

    path_parts = path.split("/")
    filename = path_parts[-1]
    filename_parts = filename.split(".")
    # check: first part shoud be job name
    if not filename_parts[0].startswith(job_name) or (
        len(filename_parts[0]) > len(job_name)
        and filename_parts[0][len(job_name)] != "-"
    ):
        print(
            f"  {AnsiColors.YELLOW}⚠ report '{path}' ({type}): filename (no extension) should preferably start with '{job_name}'{AnsiColors.RESET}"
        )
    # check: filename made of three parts
    if len(filename_parts) != 3:
        print(
            f"  {AnsiColors.YELLOW}⚠ report '{path}' ({type}): filename should preferably be in 3 parts '<job-name>.<format>.<extension>'{AnsiColors.RESET}"
        )
    else:
        # check: second part (format) matches report type
        if expected_formats and filename_parts[1] not in expected_formats:
            print(
                f"  {AnsiColors.RED}✕ report '{path}' ({type}): filename middle part must match '{'|'.join(expected_formats)}'{AnsiColors.RESET}"
            )
            err_count += 1

    # check: extension matches report type
    if expected_exts and filename_parts[-1] not in expected_exts:
        print(
            f"  {AnsiColors.RED}✕ report '{path}' ({type}): file extension must match '{'|'.join(expected_exts)}'{AnsiColors.RESET}"
        )
        err_count += 1

    # check: containing folder is 'reports/'
    if len(path_parts) < 2 or path_parts[-2] != "reports":
        print(
            f"  {AnsiColors.YELLOW}⚠ report '{path}' ({type}): should preferably be generated in a 'reports/' folder{AnsiColors.RESET}"
        )

    return err_count


def _check_job(job_name: str, tpl_body, job_prefix) -> int:
    err_count = 0

    # check non-hiden jobs rules
    if not job_name.startswith("."):
        # check: all jobs are prefixed with the template prefix
        if not job_name.startswith(job_prefix) or (
            len(job_name) > len(job_prefix) and job_name[len(job_prefix)] != "-"
        ):
            print(
                f"  {AnsiColors.RED}✕ job <{job_name}>: doesn't start with prefix ({job_prefix}){AnsiColors.RESET}"
            )
            err_count += 1

        # check: tbc reports are compliant to naming convention
        reports = tpl_body[job_name].get("artifacts", {}).get("reports", {})
        for type, paths in reports.items():
            if type == "coverage_report":
                paths = paths["path"]
            if isinstance(paths, str):
                paths = [paths]
            for path in paths:
                err_count += _check_report(job_name, type, path)

    return err_count


def _check_tpl(
    tpl_desc, main_tpl_desc, project_dir: Path, var_prefix: str, doc_vars: list[DocVar]
    kicker: dict[str, any],
    root_kicker: Optional[dict[str, any]],
    project_dir: Path,
    prefix: str,
    job_prefix: str,
    doc_vars: list[DocVar],
) -> int:
    tpl_path = project_dir / tpl_desc["template_path"]
    """Check a template (either main or variant)."""
    tpl_path = project_dir / kicker["template_path"]
    if not tpl_path.exists():
        print(f"{AnsiColors.RED}ERROR: Template file ({tpl_path}) not found: abort")
        exit(1)

    # load template
    with open(tpl_path, "r") as reader:
        all_tpl = list(yaml.load_all(reader, Loader=yaml.BaseLoader))
        tpl_spec = all_tpl[0]
        tpl_impl = all_tpl[-1]
        tpl_parts: list[dict[str, Any]] = list(
            yaml.load_all(reader, Loader=yaml.BaseLoader)
        )
        tpl_spec: dict[str, Any] = tpl_parts[0]
        tpl_body: dict[str, Any] = tpl_parts[-1]

    inputs: dict[str, dict[str, any]] = dict(tpl_spec["spec"]["inputs"])
    err_count = 0
    # check main template variables
    for var in tpl_desc.get("variables", []):
        tbc_var = TbcVar.parse_obj(var)

    # check jobs
    # ----------
    for name, body in tpl_body.items():
        if "stage" not in body and "extends" not in body:
            # not a job?,
            continue
        err_count += _check_job(name, tpl_body, job_prefix)

    # check variables
    # ---------------
    var_prefix = prefix.upper() + "_"

    # check main variables
    for var in kicker.get("variables", []):
        tbc_var = TbcVar.model_validate(var)
        err_count += _check_var(
            tbc_var, var_prefix, tpl_spec, tpl_impl, main_tpl_desc, doc_vars
            tbc_var, var_prefix, tpl_spec, tpl_body, root_kicker, doc_vars
        )
        input_name = tbc_var.input_name(var_prefix)
        if input_name in inputs:
            del inputs[input_name]

    # check feature variables
    for feat in tpl_desc.get("features", []):
    for feat in kicker.get("features", []):
        if feat.get("enable_with"):
            tbc_var = TbcVar(
                name=feat.get("enable_with"),
@@ -347,7 +523,7 @@ def _check_tpl(
                type=TbcVarType.boolean,
            )
            err_count += _check_var(
                tbc_var, var_prefix, tpl_spec, tpl_impl, main_tpl_desc, doc_vars
                tbc_var, var_prefix, tpl_spec, tpl_body, root_kicker, doc_vars
            )
            input_name = tbc_var.input_name(var_prefix)
            if input_name in inputs:
@@ -359,15 +535,15 @@ def _check_tpl(
                type=TbcVarType.boolean,
            )
            err_count += _check_var(
                tbc_var, var_prefix, tpl_spec, tpl_impl, main_tpl_desc, doc_vars
                tbc_var, var_prefix, tpl_spec, tpl_body, root_kicker, doc_vars
            )
            input_name = tbc_var.input_name(var_prefix)
            if input_name in inputs:
                del inputs[input_name]
        for var in feat.get("variables", []):
            tbc_var = TbcVar.parse_obj(var)
            tbc_var = TbcVar.model_validate(var)
            err_count += _check_var(
                tbc_var, var_prefix, tpl_spec, tpl_impl, main_tpl_desc, doc_vars
                tbc_var, var_prefix, tpl_spec, tpl_body, root_kicker, doc_vars
            )
            input_name = tbc_var.input_name(var_prefix)
            if input_name in inputs:
@@ -463,11 +639,11 @@ def run():
        )
        or kicker["name"].lower()
    )
    var_prefix = prefix.upper() + "_"
    job_prefix: str = kicker.get("job_prefix", prefix)

    print("=============================================================")
    print(
        f"Checking template {AnsiColors.CYAN}{kicker['name']}{AnsiColors.RESET} (vars prefix {AnsiColors.CYAN}\"{var_prefix}\"{AnsiColors.RESET})"
        f"Checking template {AnsiColors.CYAN}{kicker['name']}{AnsiColors.RESET} (prefix {AnsiColors.CYAN}\"{prefix}\"{AnsiColors.RESET} / job prefix {AnsiColors.CYAN}\"{job_prefix}\"{AnsiColors.RESET})"
    )
    print("=============================================================")
    if not kicker.get("is_component"):
@@ -480,14 +656,16 @@ def run():
    print(
        f"{AnsiColors.BLUE}{AnsiColors.BOLD}→ Main template ({kicker['template_path']}){AnsiColors.RESET}"
    )
    err_count = _check_tpl(kicker, None, project_dir, var_prefix, doc_vars)
    err_count = _check_tpl(kicker, None, project_dir, prefix, job_prefix, doc_vars)

    # Check variants
    for variant in kicker.get("variants", []):
        print(
            f"{AnsiColors.BLUE}{AnsiColors.BOLD}{variant['name']} variant ({variant['template_path']}){AnsiColors.RESET}"
        )
        err_count += _check_tpl(variant, kicker, project_dir, var_prefix, doc_vars)
        err_count += _check_tpl(
            variant, kicker, project_dir, prefix, job_prefix, doc_vars
        )

    if err_count > 0:
        exit(127)
Loading