mash-playbook/bin/optimize.py
Slavi Pantaleev d2c9ed3e45 Initial work on optimization commands
The playbook can now optimize itself based on the enabled components in
for all hosts in the inventory (`just optimize`) or for a specific host
(`just optimize-for-host HOSTNAME`).

The optimized playbook will have:

- fewer requirements (fewer roles need to be installed by `just roles`)
- a shorter and quicker to evaluate `group_vars/mash_servers` file
- a `setup.yml` file which includes less roles

Running the playbook optimized is still work in progress.
There still probably exist various role dependencies in the group-vars file, etc.

The `optimize-reset` command aims to restore your playbook to a
non-optimized state, which should work as before (and not experience bugs).

The playbook takes care to notice of changes to the various files in
`templates/` (`setup.yml`, `requirements.yml`, `group_vars_mash_servers`)
and update your optimized or non-optimized copies that are derived from
these templates. To do this, it keeps `.srchash` files in the `run/` directory.
When it notices a change in the source file's hash (by comparing to the `.srchash` file),
it will update you to the new template.

Optimization state is stored in a file in `run/` as well (`optimization-vars-files.state`).
Should the playbook notice changes in the source `template/` files, it
should update you and re-optimize using the same parameters as before (read from the state file).
2023-11-20 16:29:06 +02:00

161 lines
6.6 KiB
Python

import argparse
import regex
import sys
import yaml
parser = argparse.ArgumentParser(description='Optimizes the playbook based on enabled components found in vars.yml files')
parser.add_argument('--vars-paths', help='Path to vars.yml configuration files to process', required=True)
parser.add_argument('--src-requirements-yml-path', help='Path to source requirements.yml file with all role definitions', required=True)
parser.add_argument('--src-setup-yml-path', help='Path to source setup.yml file', required=True)
parser.add_argument('--src-group-vars-yml-path', help='Path to source group vars file', required=True)
parser.add_argument('--dst-requirements-yml-path', help='Path to destination requirements.yml file, where role definitions will be saved', required=True)
parser.add_argument('--dst-setup-yml-path', help='Path to destination setup.yml file', required=True)
parser.add_argument('--dst-group-vars-yml-path', help='Path to destination group vars file', required=True)
args = parser.parse_args()
def load_combined_variable_names_from_files(vars_yml_file_paths):
variable_names = set({})
for vars_path in vars_yml_file_paths:
with open(vars_path, 'r') as file:
yaml_data = yaml.safe_load(file)
variable_names = variable_names | set(yaml_data.keys())
return variable_names
def load_yaml_file(path):
with open(path, 'r') as file:
return yaml.safe_load(file)
def is_role_definition_in_use(role_definition, used_variable_names):
for variable_name in used_variable_names:
if 'activation_prefix' in role_definition:
if role_definition['activation_prefix'] == '':
# Special value indicating "always activate".
# We don't really need this dedicated if, but it's more obvious with it.
return True
if variable_name.startswith(role_definition['activation_prefix']):
return True
return False
def write_yaml_to_file(definitions, path):
with open(path, 'w') as file:
yaml.dump(definitions, file)
def read_file(path):
with open(path, 'r') as file:
return file.read()
def write_to_file(contents, path):
with open(path, 'w') as file:
file.write(contents)
# Matches the beginning of role-specific blocks.
# Example: `# role-specific:playbook_help`
regex_role_specific_block_start = regex.compile('^\s*#\s*role-specific\:\s*([^\s]+)$')
# Matches the end of role-specific blocks.
# Example: `# /role-specific:playbook_help`
regex_role_specific_block_end = regex.compile('^\s*#\s*/role-specific\:\s*([^\s]+)$')
def process_file_contents(file_name, enabled_role_names, known_role_names):
contents = read_file(file_name)
lines_preserved = []
role_specific_stack = []
for line_number, line in enumerate(contents.split("\n")):
# Stage 1: looking for a role-specific starting block
start_role_matches = regex_role_specific_block_start.match(line)
if start_role_matches is not None:
role_name = start_role_matches.group(1)
if role_name not in known_role_names:
raise Exception('Found start block for role {0} on line {1} in file {2}, but it is not a known role name found among: {3}'.format(
role_name,
line_number,
file_name,
known_role_names,
))
role_specific_stack.append(role_name)
continue
# Stage 2: looking for role-specific closing blocks
end_role_matches = regex_role_specific_block_end.match(line)
if end_role_matches is not None:
role_name = end_role_matches.group(1)
if role_name not in known_role_names:
raise Exception('Found end block for role {0} on line {1} in file {2}, but it is not a known role name found among: {3}'.format(
role_name,
line_number,
file_name,
known_role_names,
))
if len(role_specific_stack) == 0:
raise Exception('Found end block for role {0} on line {1} in file {2}, but there is no opening statement for it'.format(
role_name,
line_number,
file_name,
))
last_role_name = role_specific_stack[len(role_specific_stack) - 1]
if role_name != last_role_name:
raise Exception('Found end block for role {0} on line {1} in file {2}, but the last starting block was for role {3}'.format(
role_name,
line_number,
file_name,
last_role_name,
))
role_specific_stack.pop()
continue
# Stage 3: regular line
all_roles_allowed = True
for role_name in role_specific_stack:
if role_name not in enabled_role_names:
all_roles_allowed = False
break
if all_roles_allowed:
lines_preserved.append(line)
if len(role_specific_stack) != 0:
raise Exception('Expected one or more closing block for role-specific tags in file {0}: {1}'.format(file_name, role_specific_stack))
lines_final = []
sequential_blank_lines_count = 0
for line in lines_preserved:
if line != "":
lines_final.append(line)
sequential_blank_lines_count = 0
continue
if sequential_blank_lines_count <= 1:
lines_final.append(line)
sequential_blank_lines_count += 1
continue
return "\n".join(lines_final)
vars_paths = args.vars_paths.split(' ')
used_variable_names = load_combined_variable_names_from_files(vars_paths)
all_role_definitions = load_yaml_file(args.src_requirements_yml_path)
enabled_role_definitions = []
for role_definition in all_role_definitions:
if is_role_definition_in_use(role_definition, used_variable_names):
enabled_role_definitions.append(role_definition)
write_yaml_to_file(enabled_role_definitions, args.dst_requirements_yml_path)
known_role_names = tuple(map(lambda definition: definition['name'], all_role_definitions))
enabled_role_names = tuple(map(lambda definition: definition['name'], enabled_role_definitions))
setup_yml_processed = process_file_contents(args.src_setup_yml_path, enabled_role_names, known_role_names)
write_to_file(setup_yml_processed, args.dst_setup_yml_path)
group_vars_yml_processed = process_file_contents(args.src_group_vars_yml_path, enabled_role_names, known_role_names)
write_to_file(group_vars_yml_processed, args.dst_group_vars_yml_path)