diff --git a/cli/create_docker_role.py b/cli/create_docker_role.py index 4a81028f..f6f89690 100644 --- a/cli/create_docker_role.py +++ b/cli/create_docker_role.py @@ -3,10 +3,10 @@ import argparse import os import shutil import sys -import yaml import ipaddress import difflib from jinja2 import Environment, FileSystemLoader +from ruamel.yaml import YAML # Paths to the group-vars files PORTS_FILE = './group_vars/all/09_ports.yml' @@ -14,43 +14,48 @@ NETWORKS_FILE = './group_vars/all/10_networks.yml' ROLE_TEMPLATE_DIR = './templates/docker_role' ROLES_DIR = './roles' +yaml = YAML() +yaml.preserve_quotes = True -def load_yaml(path): + +def load_yaml_with_comments(path): with open(path) as f: - return yaml.safe_load(f) + return yaml.load(f) -def dump_yaml(data, path): +def dump_yaml_with_comments(data, path): with open(path, 'w') as f: - yaml.safe_dump(data, f, sort_keys=False) + yaml.dump(data, f) def get_next_network(networks_dict, prefixlen): - """Select the next contiguous subnet by taking the highest existing subnet and adding one network-size offset.""" + """Select the next contiguous subnet, based on the highest existing subnet + one network offset.""" nets = [] - for info in networks_dict['defaults_networks']['local'].values(): + local = networks_dict['defaults_networks']['local'] + for name, info in local.items(): + # info is a dict with 'subnet' key net = ipaddress.ip_network(info['subnet']) if net.prefixlen == prefixlen: nets.append(net) if not nets: - raise RuntimeError(f"No existing /{prefixlen} networks to base allocation on.") + raise RuntimeError(f"No existing /{prefixlen} subnets to base allocation on.") nets.sort(key=lambda n: int(n.network_address)) last = nets[-1] offset = last.num_addresses - next_network_address = int(last.network_address) + offset - return ipaddress.ip_network((next_network_address, prefixlen)) + next_net = ipaddress.ip_network((int(last.network_address) + offset, prefixlen)) + return next_net def get_next_port(ports_dict, category): - """Assign the next port by taking max existing and adding one.""" - existing = [int(p) for p in ports_dict['ports']['localhost'].get(category, {}).values()] + """Assign the next port by taking the max existing plus one.""" + loc = ports_dict['ports']['localhost'][category] + existing = [int(v) for v in loc.values()] return (max(existing) + 1) if existing else 1 def prompt_conflict(dst_file): - """Prompt the user to resolve a file conflict.""" print(f"Conflict detected: {dst_file}") - print("Choose action: [1] overwrite, [2] skip, [3] merge") + print("[1] overwrite, [2] skip, [3] merge") choice = None while choice not in ('1', '2', '3'): choice = input("Enter 1, 2, or 3: ").strip() @@ -58,12 +63,7 @@ def prompt_conflict(dst_file): def render_templates(src_dir, dst_dir, context): - """Recursively render all templates from src_dir into dst_dir, handling conflicts.""" - env = Environment( - loader=FileSystemLoader(src_dir), - keep_trailing_newline=True, - autoescape=False, - ) + env = Environment(loader=FileSystemLoader(src_dir), keep_trailing_newline=True, autoescape=False) env.filters['bool'] = lambda x: bool(x) for root, _, files in os.walk(src_dir): @@ -73,89 +73,91 @@ def render_templates(src_dir, dst_dir, context): for fn in files: tpl = env.get_template(os.path.join(rel, fn)) rendered = tpl.render(**context) - out_name = fn[:-3] if fn.endswith('.j2') else fn - dst_file = os.path.join(target, out_name) + out = fn[:-3] if fn.endswith('.j2') else fn + dst_file = os.path.join(target, out) if os.path.exists(dst_file): choice = prompt_conflict(dst_file) - if choice == '2': # skip + if choice == '2': print(f"Skipping {dst_file}") continue - if choice == '3': # merge: append lines not present + if choice == '3': with open(dst_file) as f_old: - old = f_old.readlines() - new = rendered.splitlines(keepends=True) - add = [l for l in new if l not in old] - if add: + old_lines = f_old.readlines() + new_lines = rendered.splitlines(keepends=True) + additions = [l for l in new_lines if l not in old_lines] + if additions: with open(dst_file, 'a') as f: - f.writelines(add) - print(f"Merged {len(add)} new lines into {dst_file}") + f.writelines(additions) + print(f"Merged {len(additions)} lines into {dst_file}") else: - print(f"Nothing new to merge in {dst_file}") + print(f"No new lines to merge into {dst_file}") continue # overwrite print(f"Overwriting {dst_file}") with open(dst_file, 'w') as f: f.write(rendered) else: - # new file + # create new file with open(dst_file, 'w') as f: f.write(rendered) def main(): - # load dynamic port categories - ports_yaml = load_yaml(PORTS_FILE) - categories = list(ports_yaml.get('ports', {}).get('localhost', {}).keys()) + # Load dynamic port categories + ports_data = load_yaml_with_comments(PORTS_FILE) + categories = list(ports_data['ports']['localhost'].keys()) parser = argparse.ArgumentParser( - description="Create or update a Docker Ansible role, assign network and ports globally" + description="Create or update a Docker Ansible role, and globally assign network and ports with comments preserved" ) parser.add_argument('-a', '--application-id', required=True, help="Unique application ID") - parser.add_argument('-n', '--network', choices=['24', '28'], required=True, - help="Network prefix length (/24 or /28)") - parser.add_argument('-p', '--ports', nargs='+', choices=categories, required=True, - help=f"Port categories to assign (allowed: {', '.join(categories)})") + parser.add_argument('-n', '--network', choices=['24', '28'], required=True, help="Network prefix length (/24 or /28)") + parser.add_argument('-p', '--ports', nargs='+', choices=categories, required=True, help=f"Port categories to assign (allowed: {', '.join(categories)})") args = parser.parse_args() app = args.application_id role = f"docker-{app}" role_dir = os.path.join(ROLES_DIR, role) - # ensure role dir exists, prompt if updating if os.path.exists(role_dir): - if input(f"Role {role} exists. Continue update? [y/N]: ").strip().lower() != 'y': - print("Aborted.") + if input(f"Role {role} exists. Continue? [y/N]: ").strip().lower() != 'y': + print("Aborting.") sys.exit(1) else: os.makedirs(role_dir) - # 1) render templates - render_templates(ROLE_TEMPLATE_DIR, role_dir, {'application_id': app, - 'role_name': role, - 'database_type': 0}) + # 1) Render all templates with conflict handling + render_templates(ROLE_TEMPLATE_DIR, role_dir, {'application_id': app, 'role_name': role, 'database_type': 0}) print(f"→ Templates applied to {role_dir}") - # 2) assign and update global networks - nets = load_yaml(NETWORKS_FILE) - net = get_next_network(nets, int(args.network)) - nets['defaults_networks']['local'][app] = str(net) - dump_yaml(nets, NETWORKS_FILE) - print(f"→ Assigned network {net} to {app} in {NETWORKS_FILE}") + # 2) Update global networks file, preserving comments + networks = load_yaml_with_comments(NETWORKS_FILE) + prefix = int(args.network) + new_net = get_next_network(networks, prefix) + networks['defaults_networks']['local'][app] = {'subnet': str(new_net)} + shutil.copy(NETWORKS_FILE, NETWORKS_FILE + '.bak') + dump_yaml_with_comments(networks, NETWORKS_FILE) + print(f"→ Assigned network {new_net} in {NETWORKS_FILE}") - # 3) assign and update global ports + # 3) Update global ports file, preserving comments + ports_data = load_yaml_with_comments(PORTS_FILE) assigned = {} for cat in args.ports: - port = get_next_port(ports_yaml, cat) - ports_yaml['ports']['localhost'].setdefault(cat, {})[app] = port - assigned[cat] = port + loc = ports_data['ports']['localhost'].setdefault(cat, {}) + if app in loc: + print(f"→ Existing port for {cat} and {app}: {loc[app]}, skipping.") + else: + pnum = get_next_port(ports_data, cat) + loc[app] = pnum + assigned[cat] = pnum if assigned: shutil.copy(PORTS_FILE, PORTS_FILE + '.bak') - dump_yaml(ports_yaml, PORTS_FILE) - print(f"→ Assigned ports {assigned} for {app} in {PORTS_FILE}") + dump_yaml_with_comments(ports_data, PORTS_FILE) + print(f"→ Assigned ports {assigned} in {PORTS_FILE}") else: - print("→ No ports assigned (already existed)") + print("→ No new ports assigned.") if __name__ == '__main__': main()