dynamic-infra-tooling/infra_cli/main.py
2026-02-06 08:36:38 +01:00

403 lines
No EOL
12 KiB
Python

import click
from .config import Config
from .dns import DNSManager
from .ingress import IngressManager
from .router import RouterManager
from .proxmox import ProxmoxManager
from .samba import SambaManager
from .cloudflare import CloudflareManager
from .database import DatabaseManager
from .cert import CertificateManager
import sys
import os
@click.group()
@click.option('--config', help='Path to config file')
@click.pass_context
def cli(ctx, config):
"""LoopAware Infrastructure Management CLI"""
try:
ctx.obj = Config(config)
except Exception as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
@cli.command()
@click.option('--domain', help='Public domain to remove from HAProxy and DNS')
@click.option('--mac', help='MAC address to remove from DHCP')
@click.option('--vmid', type=int, help='LXC VMID to destroy')
@click.option('--node', help='Proxmox node for the VMID')
@click.option('--port-name', help='Router port forward section name to delete')
@click.pass_obj
def decommission(config, domain, mac, vmid, node, port_name):
"""Orchestrated removal of a service from all infrastructure layers"""
if domain:
click.echo(f"Removing Ingress/DNS for {domain}...")
try:
IngressManager(config).remove(domain)
DNSManager(config).remove_dns(domain)
except Exception as e:
click.echo(f"Warning: Ingress/DNS cleanup failed: {e}")
if mac:
click.echo(f"Removing DHCP reservation for {mac}...")
try:
DNSManager(config).remove_host(mac)
except Exception as e:
click.echo(f"Warning: DHCP cleanup failed: {e}")
if port_name:
click.echo(f"Removing Router Port Forward {port_name}...")
try:
RouterManager(config).remove_forward(port_name)
except Exception as e:
click.echo(f"Warning: Router cleanup failed: {e}")
if vmid:
click.echo(f"Destroying LXC {vmid} on {node or 'default node'}...")
try:
ProxmoxManager(config, node).delete_lxc(vmid)
except Exception as e:
click.echo(f"Warning: Proxmox cleanup failed: {e}")
click.echo("Decommission process complete.")
@cli.group()
def db():
"""Manage PostgreSQL Databases and Users"""
pass
@db.command(name='list-dbs')
@click.pass_obj
def db_list_dbs(config):
mgr = DatabaseManager(config)
click.echo(mgr.list_databases())
@db.command(name='list-users')
@click.pass_obj
def db_list_users(config):
mgr = DatabaseManager(config)
click.echo(mgr.list_users())
@db.command(name='provision')
@click.argument('project_name')
@click.option('--password', help='Database user password')
@click.pass_obj
def db_provision(config, project_name, password):
"""Create a database and user for a project"""
import secrets
import string
db_name = project_name.lower().replace("-", "_")
username = f"{db_name}_user"
pwd = password or ''.join(secrets.choice(string.ascii_letters + string.digits) for i in range(16))
mgr = DatabaseManager(config)
click.echo(f"Creating user {username}...")
mgr.create_user(username, pwd)
click.echo(f"Creating database {db_name} owned by {username}...")
mgr.create_database(db_name, owner=username)
click.echo("\nProvisioning Complete:")
click.echo(f" DB Name: {db_name}")
click.echo(f" Username: {username}")
click.echo(f" Password: {pwd}")
@cli.group()
def cloudflare():
"""Manage Cloudflare DNS and DDNS"""
pass
@cloudflare.command(name='list-ddns')
@click.pass_obj
def cf_list_ddns(config):
mgr = CloudflareManager(config)
for domain in mgr.list_domains():
click.echo(domain)
@cloudflare.command(name='add-ddns')
@click.argument('domain')
@click.pass_obj
def cf_add_ddns(config, domain):
mgr = CloudflareManager(config)
if mgr.add_domain(domain):
click.echo(f"Added {domain} to DDNS update list")
else:
click.echo(f"{domain} already in DDNS update list")
@cloudflare.command(name='remove-ddns')
@click.argument('domain')
@click.pass_obj
def cf_remove_ddns(config, domain):
mgr = CloudflareManager(config)
if mgr.remove_domain(domain):
click.echo(f"Removed {domain} from DDNS update list")
else:
click.echo(f"{domain} not found in DDNS update list")
@cloudflare.command(name='update-ddns')
@click.option('--force', is_flag=True, help='Force update even if IP matches')
@click.pass_obj
def cf_update_ddns(config, force):
mgr = CloudflareManager(config)
click.echo("Updating Cloudflare DDNS records...")
click.echo(mgr.update_ddns(force))
@cli.group()
def proxmox():
"""Manage Proxmox VMs and Containers"""
pass
@proxmox.command(name='list-lxcs')
@click.option('--node', help='Proxmox node name')
@click.pass_obj
def proxmox_list_lxcs(config, node):
mgr = ProxmoxManager(config, node)
click.echo(mgr.list_lxcs())
@proxmox.command(name='create-lxc')
@click.option('--vmid', help='VMID for the container (auto-discovered if omitted)')
@click.argument('template')
@click.argument('hostname')
@click.argument('ip')
@click.argument('gateway')
@click.option('--node', help='Proxmox node name')
@click.option('--password', help='Root password for LXC')
@click.pass_obj
def proxmox_create_lxc(config, vmid, template, hostname, ip, gateway, node, password):
mgr = ProxmoxManager(config, node)
target_vmid = vmid
if not target_vmid:
click.echo("Discovering next available VMID...")
target_vmid = mgr.get_next_vmid()
if not target_vmid:
click.echo("Error: Could not automatically discover next VMID. Please provide one with --vmid", err=True)
sys.exit(1)
click.echo(f"Using VMID: {target_vmid}")
mgr.create_lxc(target_vmid, template, hostname, ip, gateway, password=password)
click.echo(f"LXC {target_vmid} ({hostname}) created on {mgr.node_name or 'default node'}")
@cli.group()
def samba():
"""Manage Samba AD Identity"""
pass
@samba.command(name='list-users')
@click.pass_obj
def samba_list_users(config):
mgr = SambaManager(config)
click.echo(mgr.list_users())
@samba.command(name='add-user')
@click.argument('username')
@click.argument('password')
@click.pass_obj
def samba_add_user(config, username, password):
mgr = SambaManager(config)
mgr.add_user(username, password)
click.echo(f"User {username} created")
@samba.command(name='add-to-group')
@click.argument('group')
@click.argument('username')
@click.pass_obj
def samba_add_to_group(config, group, username):
mgr = SambaManager(config)
mgr.add_to_group(group, username)
click.echo(f"User {username} added to group {group}")
@cli.group()
def dns():
"""Manage DNS and DHCP"""
pass
@dns.command(name='add-host')
@click.argument('mac')
@click.argument('ip')
@click.argument('hostname')
@click.pass_obj
def dns_add_host(config, mac, ip, hostname):
mgr = DNSManager(config)
mgr.add_host(mac, ip, hostname)
click.echo(f"Added host {hostname} ({ip})")
@dns.command(name='remove-host')
@click.argument('mac')
@click.pass_obj
def dns_remove_host(config, mac):
mgr = DNSManager(config)
mgr.remove_host(mac)
click.echo(f"Removed host {mac}")
@dns.command(name='add-dns')
@click.argument('domain')
@click.argument('ip')
@click.pass_obj
def dns_add_dns(config, domain, ip):
mgr = DNSManager(config)
mgr.add_dns(domain, ip)
click.echo(f"Added DNS record for {domain} -> {ip}")
@dns.command(name='remove-dns')
@click.argument('domain')
@click.pass_obj
def dns_remove_dns(config, domain):
mgr = DNSManager(config)
mgr.remove_dns(domain)
click.echo(f"Removed DNS record for {domain}")
@dns.command(name='list')
@click.pass_obj
def dns_list(config):
mgr = DNSManager(config)
data = mgr.list()
click.echo(data['hosts'])
click.echo(data['dns'])
@cli.group()
def ip():
"""Manage and discover available IP addresses"""
pass
@ip.command(name='list-free')
@click.option('--count', default=5, help='Number of free IPs to show')
@click.pass_obj
def ip_list_free(config, count):
mgr = DNSManager(config)
free = mgr.get_free_ips()
for ip in free[:count]:
click.echo(ip)
@ip.command(name='next-free')
@click.pass_obj
def ip_next_free(config):
mgr = DNSManager(config)
free = mgr.get_free_ips()
if free:
click.echo(free[0])
else:
click.echo("Error: No free IPs in agent pool!", err=True)
sys.exit(1)
@cli.group()
def ingress():
"""Manage HAProxy Ingress"""
pass
@ingress.command(name='add')
@click.argument('domain')
@click.argument('ip')
@click.argument('port', type=int)
@click.option('--https', is_flag=True, help='Target uses HTTPS')
@click.pass_obj
def ingress_add(config, domain, ip, port, https):
mgr = IngressManager(config)
mgr.add(domain, ip, port, https)
click.echo(f"Added ingress for {domain}")
@ingress.command(name='remove')
@click.argument('domain')
@click.pass_obj
def ingress_remove(config, domain):
mgr = IngressManager(config)
mgr.remove(domain)
click.echo(f"Removed ingress for {domain}")
@ingress.command(name='list')
@click.pass_obj
def ingress_list(config):
mgr = IngressManager(config)
click.echo(mgr.list())
@cli.group()
def router():
"""Manage Router Port Forwards"""
pass
@router.command(name='add')
@click.argument('name')
@click.argument('proto')
@click.argument('ext_port')
@click.argument('int_ip')
@click.argument('int_port')
@click.pass_obj
def router_add(config, name, proto, ext_port, int_ip, int_port):
import ipaddress
# Validate IP and Ports in CLI layer for better error messages
try:
ipaddress.ip_address(int_ip)
except ValueError:
raise click.BadParameter(f"Invalid internal IP address: {int_ip}")
for p in [ext_port, int_port]:
if not (1 <= int(p) <= 65535):
raise click.BadParameter(f"Port {p} out of range (1-65535)")
mgr = RouterManager(config)
mgr.add_forward(name, proto, ext_port, int_ip, int_port)
click.echo(f"Added port forward {name}")
@router.command(name='remove')
@click.argument('section')
@click.pass_obj
def router_remove(config, section):
mgr = RouterManager(config)
try:
mgr.remove_forward(section)
click.echo(f"Removed port forward {section}")
except ValueError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
@router.command(name='list')
@click.pass_obj
def router_list(config):
mgr = RouterManager(config)
for rule in mgr.list():
click.echo(f"[{rule['section']}] {rule['name']}: {rule['proto']} {rule['port']} -> {rule['dest']}")
@cli.group()
def cert():
"""Manage SSL/TLS Certificates"""
pass
@cert.command(name='list')
@click.pass_obj
def cert_list(config):
mgr = CertificateManager(config)
click.echo(mgr.list_certs())
@cert.command(name='status')
@click.pass_obj
def cert_status(config):
mgr = CertificateManager(config)
click.echo(f"Main Certificate Expiry: {mgr.check_expiry()}")
@cert.command(name='renew')
@click.option('--force', is_flag=True, help='Force full SAN discovery and renewal')
@click.pass_obj
def cert_renew(config, force):
mgr = CertificateManager(config)
click.echo("Running dynamic SAN manager...")
click.echo(mgr.renew(force))
@cert.command(name='resolve')
@click.argument('domain')
@click.pass_obj
def cert_resolve(config, domain):
"""Find the certificate file covering a specific domain"""
mgr = CertificateManager(config)
cert_file = mgr.resolve_cert_for_domain(domain)
if cert_file:
click.echo(cert_file)
else:
click.echo(f"Error: No certificate found covering {domain}", err=True)
sys.exit(1)
def main():
cli(obj={})
if __name__ == "__main__":
main()