mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-12-19 15:22:59 +00:00
188 lines
6.2 KiB
Python
188 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import shutil
|
|
import ipaddress
|
|
from jinja2 import Environment, FileSystemLoader
|
|
from ruamel.yaml import YAML
|
|
|
|
import sys
|
|
import os
|
|
from module_utils.entity_name_utils import get_entity_name
|
|
|
|
# Paths to the group-vars files
|
|
PORTS_FILE = "./group_vars/all/10_ports.yml"
|
|
NETWORKS_FILE = "./group_vars/all/09_networks.yml"
|
|
ROLE_TEMPLATE_DIR = "./templates/roles/web-app"
|
|
ROLES_DIR = "./roles"
|
|
|
|
yaml = YAML()
|
|
yaml.preserve_quotes = True
|
|
|
|
|
|
def load_yaml_with_comments(path):
|
|
with open(path) as f:
|
|
return yaml.load(f)
|
|
|
|
|
|
def dump_yaml_with_comments(data, path):
|
|
with open(path, "w") as f:
|
|
yaml.dump(data, f)
|
|
|
|
|
|
def get_next_network(networks_dict, prefixlen):
|
|
"""Select the next contiguous subnet, based on the highest existing subnet + one network offset."""
|
|
nets = []
|
|
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} subnets to base allocation on.")
|
|
nets.sort(key=lambda n: int(n.network_address))
|
|
last = nets[-1]
|
|
offset = last.num_addresses
|
|
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 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):
|
|
print(f"Conflict detected: {dst_file}")
|
|
print("[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_templates(src_dir, dst_dir, context):
|
|
env = Environment(
|
|
loader=FileSystemLoader(src_dir), keep_trailing_newline=True, autoescape=False
|
|
)
|
|
env.filters["bool"] = lambda x: bool(x)
|
|
env.filters["get_entity_name"] = get_entity_name
|
|
|
|
for root, _, files in os.walk(src_dir):
|
|
rel = os.path.relpath(root, src_dir)
|
|
target = os.path.join(dst_dir, rel)
|
|
os.makedirs(target, exist_ok=True)
|
|
for fn in files:
|
|
tpl = env.get_template(os.path.join(rel, fn))
|
|
rendered = tpl.render(**context)
|
|
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":
|
|
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)
|
|
additions = [line for line in new_lines if line not in old_lines]
|
|
if additions:
|
|
with open(dst_file, "a") as f:
|
|
f.writelines(additions)
|
|
print(f"Merged {len(additions)} lines into {dst_file}")
|
|
else:
|
|
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:
|
|
# create new file
|
|
with open(dst_file, "w") as f:
|
|
f.write(rendered)
|
|
|
|
|
|
def main():
|
|
# 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, 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)})",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
app = args.application_id
|
|
role = f"web-app-{app}"
|
|
role_dir = os.path.join(ROLES_DIR, role)
|
|
|
|
if os.path.exists(role_dir):
|
|
if input(f"Role {role} exists. Continue? [y/N]: ").strip().lower() != "y":
|
|
print("Aborting.")
|
|
sys.exit(1)
|
|
else:
|
|
os.makedirs(role_dir)
|
|
|
|
# 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) 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) Update global ports file, preserving comments
|
|
ports_data = load_yaml_with_comments(PORTS_FILE)
|
|
assigned = {}
|
|
for cat in args.ports:
|
|
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_with_comments(ports_data, PORTS_FILE)
|
|
print(f"→ Assigned ports {assigned} in {PORTS_FILE}")
|
|
else:
|
|
print("→ No new ports assigned.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|