Finished role creator

This commit is contained in:
Kevin Veen-Birkenbach 2025-07-07 04:53:18 +02:00
parent a1465ef886
commit 75d603db5b
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E

View File

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