Commit ffca7a77 authored by Clement Bois's avatar Clement Bois
Browse files

Merge branch 'feat/global-var' into 'main'

feat: support TBC_DEFAULT global variables

See merge request to-be-continuous/tools/tbc-check!103
parents 6a44cfbf d63a3899
Loading
Loading
Loading
Loading
+113 −75
Original line number Diff line number Diff line
@@ -141,6 +141,9 @@ class TbcVarType(str, Enum):
            return GlInputType.number
        return GlInputType.string

    @staticmethod
    def is_enumerated(type):
        return type == TbcVarType.enum or type == TbcVarType.boolean

T = TypeVar("T")  # Needed for type inference

@@ -212,6 +215,10 @@ class TbcVar(BaseJson):
    """Whether the variable is a secret."""
    mandatory: bool = False
    """Whether the variable is mandatory."""
    has_default: Union[bool, str] = False
    """Whether the variable has a global TBC default."""
    example: Optional[str] = None
    """Variable example value when default value is not representative."""
    pos: FilePos = None
    """The position of the variable declaration in the Kicker file."""

@@ -226,12 +233,15 @@ class TbcVar(BaseJson):
            .lower()
        )

    def default_name(self) -> str:
        return f"TBC_DEFAULT_{self.name}"

    def to_gl(self) -> GlInput:
        return GlInput(
            # TODO: remove Markdown formatting?
            description=self.description,
            type=TbcVarType.to_gl(self.type),
            default=self.gl_dflt,
            default=f"${self.default_name()}" if self.has_default else self.gl_dflt,
            options=self.values,
        )

@@ -343,6 +353,21 @@ class Check(Enum):
    var_options_should_match = Rule(
        Severity.minor, "Variable options matches Kicker declaration."
    )
    var_empty_default_should_have_example = Rule(
        Severity.minor, "Variable with empty default has an example value in Kicker."
    )
    versioned_var_should_have_a_global_default = Rule(
        Severity.minor, "Variable containing a version should set has_default in Kicker."
    )
    default_var_must_be_declared = Rule(
        Severity.major, "TBC default variable is declared in the template."
    )
    default_var_value_must_match = Rule(
        Severity.major, "TBC default variable value matches Kicker declaration."
    )
    default_var_should_be_mentioned = Rule(
        Severity.minor, "TBC default variable should be mentioned in the README description."
    )
    report_should_have_known_type = Rule(Severity.minor, "Report has a known type.")
    report_should_start_with_job_name = Rule(
        Severity.minor, "Report starts with the job name."
@@ -524,83 +549,101 @@ def _check_var(
    expected_input_name = tbc_var.input_name(var_prefix)
    expected_gl_input = tbc_var.to_gl()

    def var_issue(check: Check, description: str, pos: Optional[FilePos] = None):
        return Issue(
            check=check,
            description=f"Variable '{tbc_var.name}/{expected_input_name}': {description}",
            position=pos or tbc_var.pos,
            hash_ctx=tbc_var.name,
        )

    # check variable declaration from Kicker
    # --------------------------------------
    # check: empty default can be omitted in kicker.json
    if tbc_var.default == "":
        yield Issue(
        yield var_issue(
            Check.var_empty_default_must_be_omitted,
            f"Variable '{tbc_var.name}': empty default shall be omitted in Kicker",
            tbc_var.pos,
            f"/var:{tbc_var.name}",
            "empty default shall be omitted in Kicker",
        )

    # check: variable with empty default should have an example value in Kicker
    if not tbc_var.example and not tbc_var.default and not TbcVarType.is_enumerated(tbc_var.type):
        yield var_issue(
            Check.var_empty_default_should_have_example,
            "empty default should be completed with an example value in Kicker",
        )

    resolved_default = tpl_body["variables"].get(tbc_var.default_name(), tbc_var.default) if tbc_var.has_default else tbc_var.default

    # check container image rules
    if (
        tbc_var.name.endswith("_IMAGE")
        and tbc_var.default
        and not tbc_var.default.startswith("$")
    ):
    is_image = tbc_var.name.endswith("_IMAGE") and resolved_default and not resolved_default.startswith("$")
    if is_image:
        # check: explicit image registry
        img_parts = tbc_var.default.split("/")
        img_parts = resolved_default.split("/")
        if img_parts[0].find(".") < 0:
            yield Issue(
            yield var_issue(
                Check.container_image_must_use_explicit_registry,
                f"Variable '{tbc_var.name}': container images must explicitly specify the registry",
                tbc_var.pos,
                f"/var:{tbc_var.name}",
                "container images must explicitly specify the registry",
            )

        # check: latest tag by default
        tag = img_parts[-1].split(":")[-1] if img_parts[-1].find(":") > 0 else "latest"
        if tag != "latest":
            yield Issue(
            yield var_issue(
                Check.container_image_should_use_latest_tag,
                f"Variable '{tbc_var.name}/{expected_input_name}': container images should use 'latest' tag by default ('{tag}' found)",
                tbc_var.pos,
                f"/var:{tbc_var.name}",
                f"container images should use 'latest' tag by default ('{tag}' found)",
            )

    if not tbc_var.has_default:
        # check: variable containing a version should have a corresponding global variable
        if is_image or re.search(r"\d+\.\d+\.\d+", tbc_var.example or tbc_var.default or ""):
            yield var_issue(
                Check.versioned_var_should_have_a_global_default,
                "variable containing a version must set has_default in Kicker",
            )

    # check variable declaration from README (warn only)
    # --------------------------------------
    doc_var = next(filter(lambda dv: dv.var_name == tbc_var.name, doc_vars), None)
    if doc_var is None:
        yield Issue(
        yield var_issue(
            Check.var_should_be_documented,
            f"Variable '{tbc_var.name}': not documented in README",
            "not documented in README",
            FilePos(readme_path),
            f"/var:{tbc_var.name}",
        )
    else:
        # check default
        if doc_var.default(expected_gl_input.type) != expected_gl_input.default:
            yield Issue(
        if doc_var.default(expected_gl_input.type) != tbc_var.gl_dflt:
            yield var_issue(
                Check.var_default_value_should_match,
                f"Variable '{tbc_var.name}/{expected_input_name}': README default doesn't match Kicker's",
                "README default doesn't match Kicker's",
                doc_var.pos,
                f"/var:{tbc_var.name}",
            )

        if doc_var.lock and not tbc_var.secret:
            yield Issue(
            yield var_issue(
                Check.var_non_secret_must_not_show_lock,
                f"Variable '{tbc_var.name}': is not declared as a secret but has a lock in README",
                "is not declared as a secret but has a lock in README",
                doc_var.pos,
                f"/var:{tbc_var.name}",
            )
        elif not doc_var.lock and tbc_var.secret:
            yield Issue(
            yield var_issue(
                Check.var_secret_must_show_lock,
                f"Variable '{tbc_var.name}': is declared as a secret but has no lock in README",
                "is declared as a secret but has no lock in README",
                doc_var.pos,
                f"/var:{tbc_var.name}",
            )
        elif not has_no_input and expected_input_name != doc_var.input_name:
            yield Issue(
            yield var_issue(
                Check.var_input_name_should_match,
                f"Variable '{tbc_var.name}/{expected_input_name}': has wrong input declared in README ({doc_var.input_name})",
                f"has wrong input declared in README ({doc_var.input_name})",
                doc_var.pos,
            )

        if tbc_var.has_default and doc_var.default_cell.find(tbc_var.default_name()) < 0:
            yield var_issue(
                Check.default_var_should_be_mentioned,
                f"has a default variable but README doesn't mention it ({tbc_var.default_name()})",
                doc_var.pos,
                f"/var:{tbc_var.name}",
            )

    # retrieve declared input from template specs
@@ -611,11 +654,10 @@ def _check_var(
    if tbc_var.secret:
        # secrets should not be inputs
        if declared_input:
            yield Issue(
            yield var_issue(
                Check.secret_should_not_be_declared,
                f"Variable '{tbc_var.name}> is a secret: must not be declared",
                "is a secret: must not be declared",
                getattr(declared_input, "__pos__", None),
                f"/var:{tbc_var.name}",
            )
        # yield Issue(
        #     None,
@@ -626,11 +668,10 @@ def _check_var(
        # a variant is overriding a variable from the main template: check same type
        orig_var = _get_var(root_kicker, tbc_var.name)
        if tbc_var.type != orig_var.type:
            yield Issue(
            yield var_issue(
                Check.overridden_var_must_keep_type,
                f"Variable '{tbc_var.name}> is an override: type doesn't match original ({orig_var.type})",
                f"is an override: type doesn't match original ({orig_var.type})",
                tbc_var.pos,
                f"/var:{tbc_var.name}",
            )
            return

@@ -643,23 +684,19 @@ def _check_var(
    if tbc_var.name.startswith("TBC_"):
        # global TBC variable: skip
        if declared_input:
            yield Issue(
            yield var_issue(
                Check.global_var_must_not_be_declared_as_input,
                f"Variable '{tbc_var.name}> is global TBC: must not be declared",
                "is global TBC: must not be declared",
                getattr(declared_input, "__pos__", None),
                f"/var:{tbc_var.name}",
            )
        return
        # yield Issue(None, f"<{tbc_var.name}> is global TBC: skip")
        return

    # check if mapped GitLab input is declared
    if not declared_input:
        yield Issue(
        yield var_issue(
            Check.input_must_be_declared,
            f"Variable '{tbc_var.name}/{expected_input_name}': input not found",
            "input not found",
            FilePos(tpl_path),
            f"/var:{tbc_var.name}",
        )
        return

@@ -668,35 +705,31 @@ def _check_var(
    actual_gl_input = GlInput.fromdict(declared_input)

    if actual_gl_input.type != expected_gl_input.type:
        yield Issue(
        yield var_issue(
            Check.var_type_should_match,
            f"Variable '{tbc_var.name}/{expected_input_name}': type ({actual_gl_input.type}) doesn't match Kicker's ({expected_gl_input.type})",
            f"type ({actual_gl_input.type}) doesn't match Kicker's ({expected_gl_input.type})",
            getattr(declared_input, "__pos__", None),
            f"/var:{tbc_var.name}",
        )

    if actual_gl_input.description != expected_gl_input.description:
        yield Issue(
        yield var_issue(
            Check.var_description_should_match,
            f"Variable '{tbc_var.name}/{expected_input_name}': description doesn't match Kicker's",
            "description doesn't match Kicker's",
            getattr(declared_input, "__pos__", None),
            f"/var:{tbc_var.name}",
        )

    if actual_gl_input.default != expected_gl_input.default:
        yield Issue(
        yield var_issue(
            Check.input_default_value_must_match,
            f"Variable '{tbc_var.name}/{expected_input_name}': default ({actual_gl_input.default}) doesn't match Kicker's ({expected_gl_input.default})",
            f"default ({actual_gl_input.default}) doesn't match Kicker's ({expected_gl_input.default})",
            getattr(declared_input, "__pos__", None),
            f"/var:{tbc_var.name}",
        )

    if actual_gl_input.options != expected_gl_input.options:
        yield Issue(
        yield var_issue(
            Check.var_options_should_match,
            f"Variable '{tbc_var.name}/{expected_input_name}': options ({actual_gl_input.options}) doesn't match Kicker's ({expected_gl_input.options})",
            f"options ({actual_gl_input.options}) doesn't match Kicker's ({expected_gl_input.options})",
            getattr(declared_input, "__pos__", None),
            f"/var:{tbc_var.name}",
        )

    # check variable is initialized from input
@@ -704,28 +737,33 @@ def _check_var(
    actual_variable_value = tpl_body["variables"].get(tbc_var.name)
    expected_variable_value = f"$[[ inputs.{expected_input_name} ]]"
    if not actual_variable_value:
        yield Issue(
        yield var_issue(
            Check.var_must_be_mapped,
            f"Variable '{tbc_var.name}/{expected_input_name}': variable not mapped from input",
            "variable not mapped from input",
            getattr(declared_input, "__pos__", None),
            f"/var:{tbc_var.name}",
        )
    elif actual_variable_value != expected_variable_value:
        yield Issue(
        yield var_issue(
            Check.var_value_must_be_mapping_expr,
            f"Variable '{tbc_var.name}/{expected_input_name}': variable value ({actual_variable_value}) doesn't match expected mapping ({expected_variable_value})",
            f"variable value ({actual_variable_value}) doesn't match expected mapping ({expected_variable_value})",
            getattr(actual_variable_value, "__pos__", None),
            f"/var:{tbc_var.name}",
        )

    # TODO ?
    # # print an OK message if no error was found
    # if err_count == 0 and warn_count == 0:
    #     print(
    #         f"  {AnsiColors.GREEN}✓{AnsiColors.RESET} <{tbc_var.name}/{expected_input_name}> OK"
    #     )

    # return err_count
    if tbc_var.has_default:
        # check: tbc default variable is defined
        actual_global_variable_value = tpl_body["variables"].get(tbc_var.default_name())
        if actual_global_variable_value is None:
            yield var_issue(
                Check.default_var_must_be_declared,
                f"global variable ({tbc_var.default_name()}) not found",
                getattr(declared_input, "__pos__", None),
            )
        elif actual_global_variable_value != tbc_var.gl_dflt and expected_gl_input.default != "default":
            yield var_issue(
                Check.default_var_value_must_match,
                f"global variable value ({actual_global_variable_value}) doesn't match Kicker's default ({tbc_var.gl_dflt})",
                getattr(actual_global_variable_value, "__pos__", None),
            )


report_types_constraints = {
+9 −5
Original line number Diff line number Diff line
from typing import Optional

from tbc_check import checker


def make_var_fixture(
    name: str, description: str, default: str, type: checker.TbcVarType
    name: str, description: str, default: str, type: checker.TbcVarType, has_default: bool = False, example: Optional[str] = None
):
    input_name = name.lower().replace("_", "-")
    global_var = f"TBC_DEFAULT_{name}"
    input_type = None
    if type == checker.TbcVarType.boolean:
        input_type = "boolean"
    elif type == checker.TbcVarType.text:
        input_type = "string"

    default_str = default if default is not None else ""
    return {
        "tbc_var": checker.TbcVar(
            name=name, description=description, type=type, default=default
            name=name, description=description, type=type, default=default, has_default=has_default, example=example
        ),
        "var_prefix": "",
        "tpl_spec": {
@@ -22,19 +26,19 @@ def make_var_fixture(
                    input_name: {
                        "description": description,
                        "type": input_type,
                        "default": default,
                        "default": f"${global_var}" if has_default else default_str,
                    }
                }
            }
        },
        "tpl_body": {"variables": {name: f"$[[ inputs.{input_name} ]]"}},
        "tpl_body": {"variables": {name: f"$[[ inputs.{input_name} ]]", global_var: default_str}},
        "root_kicker": None,
        "doc_vars": [
            checker.DocVar(
                var_name=name,
                input_name=input_name,
                description=description,
                default_cell=f"`{default}`",
                default_cell=f"`{default_str}`{f" (from `${global_var}`)" if has_default else ""}",
                lock=False,
            )
        ],
+35 −1
Original line number Diff line number Diff line
@@ -10,7 +10,19 @@ KICKER_VAR_STRING = helper.make_var_fixture(
)

KICKER_VAR_STRING_W_EMPTY_DEFAULT = helper.make_var_fixture(
    name="A_STRING", description="string var", type=checker.TbcVarType.text, default=""
    name="A_STRING",
    description="string var",
    type=checker.TbcVarType.text,
    default="",
    example="some_value"
)

KICKER_VAR_STRING_EMPTY_NO_EXAMPLE = helper.make_var_fixture(
    name="A_STRING",
    description="string var",
    type=checker.TbcVarType.text,
    default=None,
    example=None
)

KICKER_VAR_IMAGE = helper.make_var_fixture(
@@ -18,6 +30,14 @@ KICKER_VAR_IMAGE = helper.make_var_fixture(
    description="test image",
    type=checker.TbcVarType.text,
    default="docker.io/library/test:latest",
    has_default=True,
)

KICKER_VAR_IMAGE_NO_GLOBAL = helper.make_var_fixture(
    name="TEST_IMAGE",
    description="test image",
    type=checker.TbcVarType.text,
    default="docker.io/library/test:latest",
)

KICKER_VAR_IMAGE_W_NO_REGISTRY = helper.make_var_fixture(
@@ -25,6 +45,7 @@ KICKER_VAR_IMAGE_W_NO_REGISTRY = helper.make_var_fixture(
    description="test image",
    type=checker.TbcVarType.text,
    default="test:latest",
    has_default=True,
)

KICKER_VAR_IMAGE_W_UNCOMMON_HOST = helper.make_var_fixture(
@@ -32,6 +53,7 @@ KICKER_VAR_IMAGE_W_UNCOMMON_HOST = helper.make_var_fixture(
    description="test image",
    type=checker.TbcVarType.text,
    default="registry.acme.tbc/test:latest",
    has_default=True,
)


@@ -48,6 +70,18 @@ def test_check_var_a_string_w_empty_default():
    assert issues[0].check == checker.Check.var_empty_default_must_be_omitted


def test_check_var_a_string_empty_no_example():
    issues: list[checker.Issue] = list(
        checker._check_var(**KICKER_VAR_STRING_EMPTY_NO_EXAMPLE)
    )
    assert len(issues) == 1
    assert issues[0].check == checker.Check.var_empty_default_should_have_example

def test_check_var_image_registry_no_global():
    issues: list[checker.Issue] = list(checker._check_var(**KICKER_VAR_IMAGE_NO_GLOBAL))
    assert len(issues) == 1
    assert issues[0].check == checker.Check.versioned_var_should_have_a_global_default

def test_check_var_image_registry_good():
    issues: list[checker.Issue] = list(checker._check_var(**KICKER_VAR_IMAGE))
    assert len(issues) == 0