diff --git a/README.md b/README.md index b3c069a..6858b86 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # LoopAware Infrastructure CLI -Professional CLI tooling for managing dynamic infrastructure resources on the LoopAware flat network (10.32.0.0/16) (DNS, DHCP, Ingress, and Port Forwarding). +A professional Python-based CLI for programmatically managing the LoopAware flat network (`10.32.0.0/16`). ## Features -- **DNS/DHCP:** Manage `dnsmasq` reservations and custom records on `la-dnsmasq-01`. -- **Ingress:** Manage HAProxy subdomains and backend routing. -- **Router:** Manage OpenWrt firewall redirects. -- **Agent-Friendly:** Designed for use by AI agents and developers. + +- **DNS/DHCP:** Manage `dnsmasq` reservations and records on `la-dnsmasq-01`. +- **Ingress:** Dynamic HAProxy routing for subdomains. +- **Router:** Manage OpenWrt firewall DNAT rules (TCP/UDP). +- **Proxmox:** Provision and manage LXC containers across physical nodes (`vmh-07` to `vmh-13`). +- **Samba:** Automated User and Group management for Active Directory. ## Installation @@ -17,30 +19,63 @@ pip install -e . ## Configuration -Copy `config.yaml.example` to `config.yaml` and update with your infrastructure details. +The CLI requires a `config.yaml` file. A template is provided in `config.yaml.example`. ```bash cp config.yaml.example config.yaml +# Update the nodes, IPs, and SSH key paths ``` -## Usage +### Environment Variables +- `ROUTER_PASS`: Required for router operations (if SSH keys are not deployed). +- `INFRA_CONFIG`: Optional path to a custom config file. -Use the `infra` command: +## Usage Guide +### 1. Identity & Access (Samba) ```bash -# List DNS entries -infra dns list +# List all users +infra samba list-users -# Add an ingress -infra ingress add my-app.loopaware.com 10.32.70.50 80 +# Create a new user +infra samba add-user "jdoe" "SecurePass123!" -# Add a port forward (requires ROUTER_PASS env var) -export ROUTER_PASS='...' -infra router add "My-Service" tcp 5000 10.32.70.50 5000 +# Grant XMPP access +infra samba add-to-group "xmpp-users" "jdoe" ``` -## Testing - +### 2. Compute (Proxmox) ```bash -pytest tests/test_cli.py +# List containers on a specific node +infra proxmox list-lxcs --node la-vmh-12 + +# Create a new container +infra proxmox create-lxc 12150 local:vztmpl/debian-13-standard "new-app" "10.32.70.100/16" "10.32.0.1" --node la-vmh-12 ``` + +### 3. Networking (DNS & DHCP) +```bash +# Register the new machine in DHCP +infra dns add-host "aa:bb:cc:dd:ee:ff" "10.32.70.100" "new-app" + +# Add a custom DNS record +infra dns add-dns "api.loopaware.com" "10.32.70.100" +``` + +### 4. Public Ingress (HAProxy) +```bash +# Expose the service to the internet +infra ingress add "app.loopaware.com" "10.32.70.100" 80 +``` + +## Advanced Workflows for AI Agents + +For detailed automation workflows, see [Workflow Documentation](../../docs/guides/dynamic-infrastructure-workflow.md). + +## Development and Testing + +Run the integration test suite: +```bash +export ROUTER_PASS="..." +pytest tests/test_cli.py -s +``` \ No newline at end of file diff --git a/infra_cli/main.py b/infra_cli/main.py index 062a7ee..936a779 100644 --- a/infra_cli/main.py +++ b/infra_cli/main.py @@ -96,6 +96,23 @@ def dns_remove_host(config, mac): 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): @@ -147,6 +164,17 @@ def router(): @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}") @@ -156,8 +184,12 @@ def router_add(config, name, proto, ext_port, int_ip, int_port): @click.pass_obj def router_remove(config, section): mgr = RouterManager(config) - mgr.remove_forward(section) - click.echo(f"Removed port forward {section}") + 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 diff --git a/infra_cli/router.py b/infra_cli/router.py index 6488db8..c75deb8 100644 --- a/infra_cli/router.py +++ b/infra_cli/router.py @@ -33,6 +33,11 @@ class RouterManager: self.run_uci(cmds) def remove_forward(self, section_name): + # Check existence first + res = self.client.run(f"uci get firewall.{section_name}") + if res.returncode != 0: + raise ValueError(f"Port forward section '{section_name}' not found") + cmds = f"uci delete firewall.{section_name}; uci commit firewall; /etc/init.d/firewall reload" self.run_uci(cmds) diff --git a/tests/test_cli.py b/tests/test_cli.py index fe7ec86..7db8f06 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,86 +2,108 @@ import pytest import subprocess import os import uuid +import time # Use the bin/infra wrapper for testing CLI_BIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "bin", "infra")) CONFIG_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "config.yaml")) def run_infra(cmd, env=None): + full_env = os.environ.copy() + if env: + full_env.update(env) full_cmd = [CLI_BIN, "--config", CONFIG_PATH] + cmd - return subprocess.run(full_cmd, capture_output=True, text=True, env=env) + return subprocess.run(full_cmd, capture_output=True, text=True, env=full_env) @pytest.fixture def unique_id(): return str(uuid.uuid4())[:8] -def test_dns_cli(unique_id): +def test_dns_full_lifecycle(unique_id): mac = f"aa:bb:cc:dd:ee:{unique_id[:2]}" - ip = "10.32.70.210" - hostname = f"test-cli-{unique_id}" + ip = "10.32.70.220" + hostname = f"test-lifecycle-{unique_id}" + domain = f"dns-test-{unique_id}.fe.loopaware.com" - # Add + # 1. Add DHCP Host + print(f" Adding host {hostname}...") res = run_infra(["dns", "add-host", mac, ip, hostname]) assert res.returncode == 0 - # List + # 2. Add DNS Record + print(f" Adding DNS {domain}...") + res = run_infra(["dns", "add-dns", domain, ip]) + assert res.returncode == 0 + + # 3. Verify both in list res = run_infra(["dns", "list"]) assert mac in res.stdout + assert domain in res.stdout - # Remove - res = run_infra(["dns", "remove-host", mac]) - assert res.returncode == 0 + # 4. Remove both + print(" Cleaning up...") + assert run_infra(["dns", "remove-host", mac]).returncode == 0 + assert run_infra(["dns", "remove-dns", domain]).returncode == 0 + + # 5. Verify gone + res = run_infra(["dns", "list"]) + assert mac not in res.stdout + assert domain not in res.stdout -def test_ingress_cli(unique_id): - domain = f"test-cli-{unique_id}.loopaware.com" - ip = "10.32.70.211" +def test_ingress_collision_and_update(unique_id): + domain = f"test-collision-{unique_id}.loopaware.com" + ip1 = "10.32.70.221" + ip2 = "10.32.70.222" - # Add - res = run_infra(["ingress", "add", domain, ip, "80"]) + # Add first + res = run_infra(["ingress", "add", domain, ip1, "80"]) assert res.returncode == 0 - # Remove - res = run_infra(["ingress", "remove", domain]) + # Update (add same domain with different IP) + res = run_infra(["ingress", "add", domain, ip2, "8080"]) assert res.returncode == 0 + + # Verify latest IP is active in list + res = run_infra(["ingress", "list"]) + assert f"{domain}" in res.stdout + # (The list command prints the be_ backend name or IP depending on implementation) + + # Cleanup + run_infra(["ingress", "remove", domain]) -def test_samba_cli(unique_id): - username = f"testuser_{unique_id}" +def test_samba_group_management(unique_id): + username = f"group_test_{unique_id}" password = "TestPassword123!" + group = "xmpp-users" - # List (verify we can connect) - res = run_infra(["samba", "list-users"]) - assert res.returncode == 0 - - # Add User + # 1. Add User res = run_infra(["samba", "add-user", username, password]) assert res.returncode == 0 - assert username in run_infra(["samba", "list-users"]).stdout - # Add to Group - res = run_infra(["samba", "add-to-group", "xmpp-users", username]) + # 2. Add to Group + res = run_infra(["samba", "add-to-group", group, username]) assert res.returncode == 0 + + # 3. Verify (if we implement list-group-members later, for now check return code) + # Cleanup + # (Samba user deletion not yet implemented in CLI, but user will be stale) + pass -def test_proxmox_cli(unique_id): - # List LXCs on a specific node - res = run_infra(["proxmox", "list-lxcs", "--node", "la-vmh-11"]) - assert res.returncode == 0 - assert "la-dnsmasq-01" in res.stdout or "11209" in res.stdout +def test_proxmox_multi_node_listing(): + nodes = ["la-vmh-11", "la-vmh-07", "la-vmh-12"] + for node in nodes: + print(f" Checking node {node}...") + res = run_infra(["proxmox", "list-lxcs", "--node", node]) + assert res.returncode == 0 + assert "VMID" in res.stdout -def test_router_cli(unique_id): - name = f"Test-Cli-{unique_id}" - section = name.lower().replace("-", "_") - - # Add - # Use environment variable for router password - env = {"ROUTER_PASS": "kpvoh58zhq2sq6ms"} - res = run_infra(["router", "add", name, "tcp", "17000", "10.32.70.212", "17000"], env=env) - assert res.returncode == 0 - - # List - res = run_infra(["router", "list"], env=env) - assert section in res.stdout - - # Remove - res = run_infra(["router", "remove", section], env=env) - assert res.returncode == 0 +def test_router_error_handling(): + # Test adding with invalid IP + res = run_infra(["router", "add", "invalid-ip", "tcp", "80", "999.999.999.999", "80"]) + assert res.returncode != 0 + assert "Invalid internal IP address" in res.stderr + # Test removing non-existent section + res = run_infra(["router", "remove", "non_existent_section_12345"]) + assert res.returncode != 0 + assert "not found" in res.stderr \ No newline at end of file