Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor templates v2 #9870

Merged
merged 13 commits into from
Nov 25, 2022
4 changes: 2 additions & 2 deletions ssg/build_sce.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@ def checks(env_yaml, yaml_path, sce_dirs, template_builder, output):

# While we don't _write_ it, we still need to parse SCE
# metadata from the templated content. Render it internally.
raw_sce_content = template_builder.get_lang_contents(
rule_id, rule.title, rule.template, langs['sce-bash'])
raw_sce_content = template_builder.get_templatable_lang_contents(rule,
langs['sce-bash'])

ext = '.sh'
filename = rule_id + ext
Expand Down
5 changes: 5 additions & 0 deletions ssg/build_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,11 @@ def load_policy_specific_content(self, rule_filename, env_yaml):
env_yaml, policy_specific_content_files)
self.policy_specific_content = policy_specific_content

def get_template_context(self, env_yaml):
ctx = super(Rule, self).get_template_context(env_yaml)
if self.identifiers:
ctx["cce_identifiers"] = self.identifiers
return ctx

def make_refs_and_identifiers_product_specific(self, product):
product_suffix = "@{0}".format(product)
Expand Down
53 changes: 53 additions & 0 deletions ssg/entities/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,59 @@ def __init__(self):
def is_templated(self):
return isinstance(self.template, dict)

def get_template_context(self, env_yaml):
# TODO: The first two variables, 'rule_id' and 'rule_title' are expected by some
# templates and macros even if they are not rendered in a rule context.
# Better name for these variables are 'entity_id' and 'entity_title'.
return {
"rule_id": self.id_,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can add entity_id as well, so the transition period can start.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather just rename it in a separate self-contained PR, without any backward-compatibility. We have enough legacy lingering around.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK.

"rule_title": self.title,
"products": env_yaml["product"],
}

def get_template_name(self):
"""
Given a template dictionary from a Rule instance, determine the name
of the template (from templates) this rule uses.
"""
try:
template_name = self.template["name"]
except KeyError:
raise ValueError(
"Templatable {0} is missing template name under template key".format(self))
return template_name

def get_template_vars(self, env_yaml):
if "vars" not in self.template:
raise ValueError(
"Templatable {0} does not contain mandatory 'vars:' key under "
"'template:' key.".format(self))
template_vars = self.template["vars"]

# Add the rule ID which will be used in template preprocessors (template.py)
# as a unique sub-element for a variety of composite IDs.
# TODO: The name _rule_id is a legacy from the era when rule was the only
# context for a template. Preprocessors implicitly depend on this name.
# A better name is '_entity_id' (as in XCCDF Entity).
template_vars["_rule_id"] = self.id_
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a good comment above it but I don't understand why a _rule_id is add here and at the same time a rule_id is add in get_template_context. Both items are set to self.id_ . I have an impression that finally both of these variables will be passed to the template so it seems to be duplicate. Any thoughts?

Copy link
Member Author

@evgenyz evgenyz Nov 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason all template.py operate with _rule_id and template.oval,ansible,etc along with marcos operate with rule_id.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be no deliberate decision regarding the initial underscore. The variable has been introduced long ago (see https://github.com/ComplianceAsCode/content/blame/453a1d53dc0e2b129fc3704545264dd076e17889/ssg/templates.py#L359) and the leading underscore was probably supplied as a preemptive measure to prevent overwrites. I would drop the assignment here, and also resort to the underscore-less usage elsewhere. We use short IDs everywhere, there is nothing to fear.

Copy link
Member Author

@evgenyz evgenyz Nov 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This dictionary element is used in:

  • shared/templates/audit_rules_privileged_commands/template.py
  • shared/templates/yamlfile_value/template.py
  • shared/templates/file_groupowner/template.py
  • shared/templates/file_owner/template.py
  • shared/templates/file_permissions/template.py
  • shared/templates/sysctl/template.py
  • shared/templates/sebool/template.py
  • ssg/utils.py

The utils.py parts are also being called from different template.pys.

What is more important templates.Teamplate.preprocess is executed without extra environment (where rule_id and rule_title reside). On top of that _rule_id is transformed into _RULE_ID as the result of this process. It's a bit messy. So, we can't just drop it.

My suggestion is to leave it for another self-contained round of refactoring.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suggestion is to leave it for another self-contained round of refactoring.

I agree, please don't do this in this PR, we can get to that later, I have created a ticket to track this effort: #9882


return make_items_product_specific(template_vars, env_yaml["product"])

def get_template_backend_langs(self):
"""
Returns list of languages that should be generated from a template
configuration, controlled by backends.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
configuration, controlled by backends.
configuration, controlled by 'backends' key of the template.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

"""
from ..templates import LANGUAGES
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the import here and not at the top?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At some point there was a circular dependency problem. I'll check if it is solved now.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is still there. Unfortunately in order to solve it I would have to extract Builder from templates.py. It totally makes sense, but it will mess this PR. How about solving it in the next one?

The problem is that Builder wants build_yaml.Rule, Rule wants common, common wants templates. Also I'm open to alternative layout suggestions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scratch that. Fixed.

if "backends" in self.template:
backends = self.template["backends"]
for lang in backends:
if lang not in LANGUAGES:
raise RuntimeError("Templatable {0} wants to generate unknown language '{1}"
.format(self, lang))
return [lang for name, lang in LANGUAGES.items() if backends.get(name, "on") == "on"]
return LANGUAGES.values()

def make_template_product_specific(self, product):
if not self.is_templated():
return
Expand Down
185 changes: 65 additions & 120 deletions ssg/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,23 @@
from ssg.build_cpe import ProductCPEs
from collections import namedtuple

templating_lang = namedtuple(

TemplatingLang = namedtuple(
"templating_language_attributes",
["name", "file_extension", "template_type", "lang_specific_dir"])

template_type = ssg.utils.enum("remediation", "check")

languages = {
"anaconda": templating_lang("anaconda", ".anaconda", template_type.remediation, "anaconda"),
"ansible": templating_lang("ansible", ".yml", template_type.remediation, "ansible"),
"bash": templating_lang("bash", ".sh", template_type.remediation, "bash"),
"blueprint": templating_lang("blueprint", ".toml", template_type.remediation, "blueprint"),
"ignition": templating_lang("ignition", ".yml", template_type.remediation, "ignition"),
"kubernetes": templating_lang("kubernetes", ".yml", template_type.remediation, "kubernetes"),
"oval": templating_lang("oval", ".xml", template_type.check, "oval"),
"puppet": templating_lang("puppet", ".pp", template_type.remediation, "puppet"),
"sce-bash": templating_lang("sce-bash", ".sh", template_type.remediation, "sce")
TemplateType = ssg.utils.enum("REMEDIATION", "CHECK")

LANGUAGES = {
"anaconda": TemplatingLang("anaconda", ".anaconda", TemplateType.REMEDIATION, "anaconda"),
"ansible": TemplatingLang("ansible", ".yml", TemplateType.REMEDIATION, "ansible"),
"bash": TemplatingLang("bash", ".sh", TemplateType.REMEDIATION, "bash"),
"blueprint": TemplatingLang("blueprint", ".toml", TemplateType.REMEDIATION, "blueprint"),
"ignition": TemplatingLang("ignition", ".yml", TemplateType.REMEDIATION, "ignition"),
"kubernetes": TemplatingLang("kubernetes", ".yml", TemplateType.REMEDIATION, "kubernetes"),
"oval": TemplatingLang("oval", ".xml", TemplateType.CHECK, "oval"),
"puppet": TemplatingLang("puppet", ".pp", TemplateType.REMEDIATION, "puppet"),
"sce-bash": TemplatingLang("sce-bash", ".sh", TemplateType.REMEDIATION, "sce")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not suggesting any change, as it is code from before, but isn't it strange that "script check engine" is treated as "remediation"? The template type probably means something else than what it says.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. No idea, but it might be somehow connected to the way we process shell scripts. SCE implementation has a lot of quirks all over the place.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be a bug, it's used in _init_lang_output_dirs in the following way:

            if lang.template_type == TemplateType.CHECK:
                output_dir = self.checks_dir
            else:
                output_dir = self.remediations_dir

It might be hidden because we don't have any SCE template in our repository.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But let's not change it in this PR.

}

PREPROCESSING_FILE_NAME = "template.py"
Expand Down Expand Up @@ -57,12 +58,12 @@ def _load(self):

template_yaml = ssg.yaml.open_raw(self.template_yaml_path)
for supported_lang in template_yaml["supported_languages"]:
if supported_lang not in languages.keys():
if supported_lang not in LANGUAGES.keys():
raise ValueError(
"The template {0} declares to support the {1} language,"
"but this language is not supported by the content.".format(
self.name, supported_lang))
lang = languages[supported_lang]
lang = LANGUAGES[supported_lang]
langfilename = lang.name + ".template"
if not os.path.exists(os.path.join(self.template_path, langfilename)):
raise ValueError(
Expand Down Expand Up @@ -134,147 +135,86 @@ def _init_and_load_templates(self):
self.templates[item] = maybe_template

def _init_lang_output_dirs(self):
for lang_name, lang in languages.items():
for lang_name, lang in LANGUAGES.items():
lang_dir = lang.lang_specific_dir
if lang.template_type == template_type.check:
if lang.template_type == TemplateType.CHECK:
output_dir = self.checks_dir
else:
output_dir = self.remediations_dir
dir_ = os.path.join(output_dir, lang_dir)
self.output_dirs[lang_name] = dir_

def get_template_backend_langs(self, template, rule_id):
"""
Returns list of languages that should be generated from a template
configuration, controlled by backends.
def get_resolved_langs_to_generate(self, templatable):
"""
if "backends" in template:
backends = template["backends"]
for lang in backends:
if lang not in languages:
raise RuntimeError("Rule {0} wants to generate unknown language '{1}"
"from a template.".format(rule_id, lang))
return [lang for name, lang in languages.items() if backends.get(name, "on") == "on"]
return languages.values()

def get_template_name(self, template, rule_id):
Given a specific Templatable instance, determine which languages are
generated by the combination of the template supported_languages AND
the Templatable's template configuration 'backends'.
"""
Given a template dictionary from a Rule instance, determine the name
of the template (from templates) this rule uses.
"""
try:
template_name = template["name"]
except KeyError:
raise ValueError(
"Rule {0} is missing template name under template key".format(
rule_id))
if not templatable.is_templated():
return []

rule_langs = set(templatable.get_template_backend_langs())
template_name = templatable.get_template_name()
if template_name not in self.templates.keys():
raise ValueError(
"Rule {0} uses template {1} which does not exist.".format(
rule_id, template_name))
return template_name

def get_resolved_langs_to_generate(self, rule):
"""
Given a specific Rule instance, determine which languages are
generated by the combination of the rule's template supported_languages AND
the rule's template configuration backends.
"""
if rule.template is None:
return None

rule_langs = set(self.get_template_backend_langs(rule.template, rule.id_))
template_name = self.get_template_name(rule.template, rule.id_)
"Templatable {0} uses template {1} which does not exist."
.format(templatable, template_name))
template_langs = set(self.templates[template_name].langs)
return rule_langs.intersection(template_langs)

def process_product_vars(self, all_variables):
"""
Given a dictionary with the format key[@<product>]=value, filter out
and only take keys that apply to this product (unqualified or qualified
to exactly this product). Returns a new dict.
"""
processed = dict(filter(lambda item: '@' not in item[0], all_variables.items()))
suffix = '@' + self.env_yaml['product']
for variable in filter(lambda key: key.endswith(suffix), all_variables):
new_variable = variable[:-len(suffix)]
value = all_variables[variable]
processed[new_variable] = value

return processed

def render_lang_file(self, template_name, template_vars, lang, local_env_yaml):
def process_template_lang_file(self, template_name, template_vars, lang, local_env_yaml):
"""
Builds and returns templated content for a given rule for a given
language; does not write the output to disk.
Processes template for a given template name and language and returns rendered content.
"""
if lang not in self.templates[template_name].langs:
return None
raise ValueError("Language {0} is not available for template {1}."
.format(lang.name, template_name))

template_file_name = lang.name + ".template"
template_file_path = os.path.join(self.templates_dir, template_name, template_file_name)
template_parameters = self.templates[template_name].preprocess(template_vars, lang.name)
env_yaml = self.env_yaml.copy()
env_yaml.update(local_env_yaml)
jinja_dict = ssg.utils.merge_dicts(env_yaml, template_parameters)
try:
filled_template = ssg.jinja.process_file_with_macros(template_file_path, jinja_dict)
except Exception as e:
print("Error in template: %s (lang: %s)" % (template_name, lang.name))
raise e
return ssg.jinja.process_file_with_macros(template_file_path, jinja_dict)

return filled_template

def get_lang_contents(self, rule_id, rule_title, template, language, extra_env=None):
def get_templatable_lang_contents(self, templatable, language):
"""
For the specified template, build and return only the specified language
content.
For the specified Templatable, build and return only the specified language content.
"""
template_name = self.get_template_name(template, rule_id)
try:
template_vars = self.process_product_vars(template["vars"])
except KeyError:
raise ValueError(
"Rule {0} does not contain mandatory 'vars:' key under "
"'template:' key.".format(rule_id))
# Add the rule ID which will be reused in OVAL templates as OVAL
# definition ID so that the build system matches the generated
# check with the rule.
template_vars["_rule_id"] = rule_id
template_name = templatable.get_template_name()
template_vars = templatable.get_template_vars(self.env_yaml)

# Checks and remediations are processed with a custom YAML dict
local_env_yaml = {"rule_id": rule_id, "rule_title": rule_title,
"products": self.env_yaml["product"]}
if extra_env is not None:
local_env_yaml.update(extra_env)

return self.render_lang_file(template_name, template_vars, language, local_env_yaml)

def build_lang(self, rule_id, rule_title, template, lang, extra_env=None):
"""
Builds templated content for a given rule for a given language.
Writes the output to the correct build directories.
"""
filled_template = self.get_lang_contents(rule_id, rule_title, template, lang, extra_env)
local_env_yaml = templatable.get_template_context(self.env_yaml)
try:
return self.process_template_lang_file(template_name, template_vars, language, local_env_yaml)
except Exception as e:
raise RuntimeError("Unable to generate {0} template language for Templatable {1}: {2}"
.format(language.name, templatable, e))

output_file_name = rule_id + lang.file_extension
def write_templatable_lang_contents(self, filled_template, lang, templatable):
output_file_name = templatable.id_ + lang.file_extension
output_filepath = os.path.join(self.output_dirs[lang.name], output_file_name)

with open(output_filepath, "w") as f:
f.write(filled_template)

def build_rule(self, rule):
def build_templatable_lang(self, templatable, lang):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer build_lang_for_templatable.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, can do that.

"""
Builds templated content for a given rule for selected languages,
Builds templated content of a given Templatable for a selected language,
writing the output to the correct build directories.
"""
extra_env = {}
if rule.identifiers is not None:
extra_env["cce_identifiers"] = rule.identifiers
filled_template = self.get_templatable_lang_contents(templatable, lang)
self.write_templatable_lang_contents(filled_template, lang, templatable)

def build_rule(self, rule):
"""
Builds templated content of a given Rule for all available languages,
writing the output to the correct build directories.
"""
for lang in self.get_resolved_langs_to_generate(rule):
if lang.name != "sce-bash":
self.build_lang(rule.id_, rule.title, rule.template, lang, extra_env)
self.build_templatable_lang(rule, lang)

def build_extra_ovals(self):
declaration_path = os.path.join(self.templates_dir, "extra_ovals.yml")
Expand All @@ -283,7 +223,12 @@ def build_extra_ovals(self):
# Since OVAL definition ID in shorthand format is always the same
# as rule ID, we can use it instead of the rule ID even if no rule
# with that ID exists
self.build_lang(oval_def_id, oval_def_id, template, languages["oval"])
rule = ssg.build_yaml.Rule.get_instance_from_full_dict({
"id_": oval_def_id,
"title": oval_def_id,
"template": template,
})
self.build_templatable_lang(rule, LANGUAGES["oval"])

def build_all_rules(self):
for rule_file in sorted(os.listdir(self.resolved_rules_dir)):
Expand All @@ -293,13 +238,13 @@ def build_all_rules(self):
except ssg.build_yaml.DocumentationNotComplete:
# Happens on non-debug build when a rule is "documentation-incomplete"
continue
if rule.template is not None:
if rule.is_templated():
self.build_rule(rule)

def build(self):
"""
Builds all templated content for all languages, writing
the output to the correct build directories.
Builds all templated content for all languages,
writing the output to the correct build directories.
"""
for dir_ in self.output_dirs.values():
if not os.path.exists(dir_):
Expand Down
9 changes: 7 additions & 2 deletions tests/unit/ssg-module/test_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ def test_render_extra_ovals():
declaration_path = os.path.join(builder.templates_dir, "extra_ovals.yml")
declaration = ssg.yaml.open_raw(declaration_path)
for oval_def_id, template in declaration.items():
oval_content = builder.get_lang_contents(oval_def_id, oval_def_id, template,
ssg.templates.languages["oval"])
rule = ssg.build_yaml.Rule.get_instance_from_full_dict({
"id_": oval_def_id,
"title": oval_def_id,
"template": template,
})
oval_content = builder.get_templatable_lang_contents(rule,
ssg.templates.LANGUAGES["oval"])
assert "<title>%s</title>" % (oval_def_id,) in oval_content