computer-playbook/cli/create_docker_role.py

200 lines
7.1 KiB
Python

#!/usr/bin/env python3
import argparse
import os
import shutil
import sys
import yaml
import ipaddress
import difflib
from jinja2 import Environment, FileSystemLoader
# Paths to the group-vars files
PORTS_FILE = './group_vars/all/09_ports.yml'
NETWORKS_FILE = './group_vars/all/10_networks.yml'
ROLE_TEMPLATE_DIR = './templates/docker_role'
ROLES_DIR = './roles'
def load_yaml(path):
with open(path) as f:
return yaml.safe_load(f)
def dump_yaml(data, path):
with open(path, 'w') as f:
yaml.safe_dump(data, f, sort_keys=False)
def get_next_network(networks_dict, prefixlen):
"""Select the first available local subnet with the given prefix length."""
nets = []
for info in networks_dict['defaults_networks']['local'].values():
net = ipaddress.ip_network(info['subnet'])
if net.prefixlen == prefixlen:
nets.append(net)
nets.sort(key=lambda n: int(n.network_address))
return nets[0]
def get_next_port(ports_dict, category):
"""Find the next unused port in the given localhost category."""
used = {int(p) for p in ports_dict['ports']['localhost'].get(category, {}).values()}
candidate = 1
while candidate in used:
candidate += 1
return candidate
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")
choice = None
while choice not in ('1', '2', '3'):
choice = input("Enter 1, 2, or 3: ").strip()
return choice
def render_template(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,
)
# Add a bool filter for Jinja evaluation
env.filters['bool'] = lambda x: bool(x)
for root, _, files in os.walk(src_dir):
rel_path = os.path.relpath(root, src_dir)
target_path = os.path.join(dst_dir, rel_path)
os.makedirs(target_path, exist_ok=True)
for filename in files:
template = env.get_template(os.path.join(rel_path, filename))
rendered = template.render(**context)
out_name = filename[:-3] if filename.endswith('.j2') else filename
dst_file = os.path.join(target_path, out_name)
if os.path.exists(dst_file):
choice = prompt_conflict(dst_file)
if choice == '2':
print(f"Skipping {dst_file}")
continue
if choice == '3':
with open(dst_file) as f_old:
old_lines = f_old.readlines()
new_lines = rendered.splitlines(keepends=True)
diff = difflib.unified_diff(
old_lines, new_lines,
fromfile=f"a/{out_name}",
tofile=f"b/{out_name}",
lineterm=''
)
diff_path = dst_file + '.diff'
with open(diff_path, 'w') as fd:
fd.writelines(diff)
print(f"Diff written to {diff_path}; please merge manually.")
continue
# Overwrite
print(f"Overwriting {dst_file}")
with open(dst_file, 'w') as f:
f.write(rendered)
def main():
# Load current port categories dynamically
ports_yaml = load_yaml(PORTS_FILE)
categories = list(ports_yaml.get('ports', {}).get('localhost', {}).keys())
parser = argparse.ArgumentParser(
description="Create or update a Docker Ansible role, and assign network and ports"
)
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)})"
)
args = parser.parse_args()
app_id = args.application_id
role_name = f"docker-{app_id}"
role_dir = os.path.join(ROLES_DIR, role_name)
# If role directory exists, ask whether to continue
if os.path.exists(role_dir):
cont = input(f"Role {role_name} already exists. Continue updating? [y/N]: ").strip().lower()
if cont != 'y':
print("Aborting.")
sys.exit(1)
else:
os.makedirs(role_dir)
# 1) Render and copy templates, with conflict resolution
# Provide database_type=0 in context for task template logic
render_template(ROLE_TEMPLATE_DIR, role_dir, {
'application_id': app_id,
'role_name': role_name,
'database_type': 0,
})
print(f"→ Templates rendered into {role_dir}")
# 2) Assign network if not already set
net_vars_file = f'./group_vars/{app_id}_network.yml'
if os.path.exists(net_vars_file):
existing_net = load_yaml(net_vars_file)
apps = existing_net.get('defaults_networks', {}).get('application', {})
if app_id in apps:
print(f"→ Network for {app_id} already configured ({apps[app_id]}), skipping.")
else:
networks = load_yaml(NETWORKS_FILE)
net = get_next_network(networks, int(args.network))
apps[app_id] = str(net)
dump_yaml(existing_net, net_vars_file)
print(f"→ Appended network {net} for {app_id}")
else:
networks = load_yaml(NETWORKS_FILE)
net = get_next_network(networks, int(args.network))
dump_yaml({'defaults_networks': {'application': {app_id: str(net)}}}, net_vars_file)
print(f"→ Created network vars file with {net} for {app_id}")
# 3) Assign ports if not present
assigned = {}
for cat in args.ports:
loc = ports_yaml['ports']['localhost'].setdefault(cat, {})
if app_id in loc:
print(f"→ Port for category '{cat}' and '{app_id}' already exists ({loc[app_id]}), skipping.")
continue
port = get_next_port(ports_yaml, cat)
loc[app_id] = port
assigned[cat] = port
if assigned:
shutil.copy(PORTS_FILE, PORTS_FILE + '.bak')
dump_yaml(ports_yaml, PORTS_FILE)
print(f"→ Assigned new ports: {assigned}")
else:
print("→ No new ports to assign, skipping update of 09_ports.yml.")
# 4) Write or merge application-specific ports file
app_ports_file = f'./group_vars/{app_id}_ports.yml'
if os.path.exists(app_ports_file):
app_ports = load_yaml(app_ports_file)
dest = app_ports.setdefault('ports', {}).setdefault('localhost', {})
for cat, port in assigned.items():
dest[cat] = port
dump_yaml(app_ports, app_ports_file)
else:
dump_yaml({'ports': {'localhost': assigned}}, app_ports_file)
print(f"→ App-specific ports written to {app_ports_file}")
if __name__ == '__main__':
main()