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 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()