docs: improve readme and add robust lifecycle tests
This commit is contained in:
parent
9c8c771cb1
commit
ce67360c3c
4 changed files with 162 additions and 68 deletions
71
README.md
71
README.md
|
|
@ -1,12 +1,14 @@
|
||||||
# LoopAware Infrastructure CLI
|
# 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
|
## Features
|
||||||
- **DNS/DHCP:** Manage `dnsmasq` reservations and custom records on `la-dnsmasq-01`.
|
|
||||||
- **Ingress:** Manage HAProxy subdomains and backend routing.
|
- **DNS/DHCP:** Manage `dnsmasq` reservations and records on `la-dnsmasq-01`.
|
||||||
- **Router:** Manage OpenWrt firewall redirects.
|
- **Ingress:** Dynamic HAProxy routing for subdomains.
|
||||||
- **Agent-Friendly:** Designed for use by AI agents and developers.
|
- **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
|
## Installation
|
||||||
|
|
||||||
|
|
@ -17,30 +19,63 @@ pip install -e .
|
||||||
|
|
||||||
## Configuration
|
## 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
|
```bash
|
||||||
cp config.yaml.example config.yaml
|
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
|
```bash
|
||||||
# List DNS entries
|
# List all users
|
||||||
infra dns list
|
infra samba list-users
|
||||||
|
|
||||||
# Add an ingress
|
# Create a new user
|
||||||
infra ingress add my-app.loopaware.com 10.32.70.50 80
|
infra samba add-user "jdoe" "SecurePass123!"
|
||||||
|
|
||||||
# Add a port forward (requires ROUTER_PASS env var)
|
# Grant XMPP access
|
||||||
export ROUTER_PASS='...'
|
infra samba add-to-group "xmpp-users" "jdoe"
|
||||||
infra router add "My-Service" tcp 5000 10.32.70.50 5000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
### 2. Compute (Proxmox)
|
||||||
|
|
||||||
```bash
|
```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
|
||||||
|
```
|
||||||
|
|
@ -96,6 +96,23 @@ def dns_remove_host(config, mac):
|
||||||
mgr.remove_host(mac)
|
mgr.remove_host(mac)
|
||||||
click.echo(f"Removed 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')
|
@dns.command(name='list')
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def dns_list(config):
|
def dns_list(config):
|
||||||
|
|
@ -147,6 +164,17 @@ def router():
|
||||||
@click.argument('int_port')
|
@click.argument('int_port')
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def router_add(config, name, proto, ext_port, int_ip, int_port):
|
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 = RouterManager(config)
|
||||||
mgr.add_forward(name, proto, ext_port, int_ip, int_port)
|
mgr.add_forward(name, proto, ext_port, int_ip, int_port)
|
||||||
click.echo(f"Added port forward {name}")
|
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
|
@click.pass_obj
|
||||||
def router_remove(config, section):
|
def router_remove(config, section):
|
||||||
mgr = RouterManager(config)
|
mgr = RouterManager(config)
|
||||||
mgr.remove_forward(section)
|
try:
|
||||||
click.echo(f"Removed port forward {section}")
|
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')
|
@router.command(name='list')
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ class RouterManager:
|
||||||
self.run_uci(cmds)
|
self.run_uci(cmds)
|
||||||
|
|
||||||
def remove_forward(self, section_name):
|
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"
|
cmds = f"uci delete firewall.{section_name}; uci commit firewall; /etc/init.d/firewall reload"
|
||||||
self.run_uci(cmds)
|
self.run_uci(cmds)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,86 +2,108 @@ import pytest
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
import time
|
||||||
|
|
||||||
# Use the bin/infra wrapper for testing
|
# Use the bin/infra wrapper for testing
|
||||||
CLI_BIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "bin", "infra"))
|
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"))
|
CONFIG_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "config.yaml"))
|
||||||
|
|
||||||
def run_infra(cmd, env=None):
|
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
|
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
|
@pytest.fixture
|
||||||
def unique_id():
|
def unique_id():
|
||||||
return str(uuid.uuid4())[:8]
|
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]}"
|
mac = f"aa:bb:cc:dd:ee:{unique_id[:2]}"
|
||||||
ip = "10.32.70.210"
|
ip = "10.32.70.220"
|
||||||
hostname = f"test-cli-{unique_id}"
|
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])
|
res = run_infra(["dns", "add-host", mac, ip, hostname])
|
||||||
assert res.returncode == 0
|
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"])
|
res = run_infra(["dns", "list"])
|
||||||
assert mac in res.stdout
|
assert mac in res.stdout
|
||||||
|
assert domain in res.stdout
|
||||||
|
|
||||||
# Remove
|
# 4. Remove both
|
||||||
res = run_infra(["dns", "remove-host", mac])
|
print(" Cleaning up...")
|
||||||
assert res.returncode == 0
|
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):
|
def test_ingress_collision_and_update(unique_id):
|
||||||
domain = f"test-cli-{unique_id}.loopaware.com"
|
domain = f"test-collision-{unique_id}.loopaware.com"
|
||||||
ip = "10.32.70.211"
|
ip1 = "10.32.70.221"
|
||||||
|
ip2 = "10.32.70.222"
|
||||||
|
|
||||||
# Add
|
# Add first
|
||||||
res = run_infra(["ingress", "add", domain, ip, "80"])
|
res = run_infra(["ingress", "add", domain, ip1, "80"])
|
||||||
assert res.returncode == 0
|
assert res.returncode == 0
|
||||||
|
|
||||||
# Remove
|
# Update (add same domain with different IP)
|
||||||
res = run_infra(["ingress", "remove", domain])
|
res = run_infra(["ingress", "add", domain, ip2, "8080"])
|
||||||
assert res.returncode == 0
|
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):
|
def test_samba_group_management(unique_id):
|
||||||
username = f"testuser_{unique_id}"
|
username = f"group_test_{unique_id}"
|
||||||
password = "TestPassword123!"
|
password = "TestPassword123!"
|
||||||
|
group = "xmpp-users"
|
||||||
|
|
||||||
# List (verify we can connect)
|
# 1. Add User
|
||||||
res = run_infra(["samba", "list-users"])
|
|
||||||
assert res.returncode == 0
|
|
||||||
|
|
||||||
# Add User
|
|
||||||
res = run_infra(["samba", "add-user", username, password])
|
res = run_infra(["samba", "add-user", username, password])
|
||||||
assert res.returncode == 0
|
assert res.returncode == 0
|
||||||
assert username in run_infra(["samba", "list-users"]).stdout
|
|
||||||
|
|
||||||
# Add to Group
|
# 2. Add to Group
|
||||||
res = run_infra(["samba", "add-to-group", "xmpp-users", username])
|
res = run_infra(["samba", "add-to-group", group, username])
|
||||||
assert res.returncode == 0
|
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):
|
def test_proxmox_multi_node_listing():
|
||||||
# List LXCs on a specific node
|
nodes = ["la-vmh-11", "la-vmh-07", "la-vmh-12"]
|
||||||
res = run_infra(["proxmox", "list-lxcs", "--node", "la-vmh-11"])
|
for node in nodes:
|
||||||
assert res.returncode == 0
|
print(f" Checking node {node}...")
|
||||||
assert "la-dnsmasq-01" in res.stdout or "11209" in res.stdout
|
res = run_infra(["proxmox", "list-lxcs", "--node", node])
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert "VMID" in res.stdout
|
||||||
|
|
||||||
def test_router_cli(unique_id):
|
def test_router_error_handling():
|
||||||
name = f"Test-Cli-{unique_id}"
|
# Test adding with invalid IP
|
||||||
section = name.lower().replace("-", "_")
|
res = run_infra(["router", "add", "invalid-ip", "tcp", "80", "999.999.999.999", "80"])
|
||||||
|
assert res.returncode != 0
|
||||||
# Add
|
assert "Invalid internal IP address" in res.stderr
|
||||||
# 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
|
|
||||||
|
|
||||||
|
# Test removing non-existent section
|
||||||
|
res = run_infra(["router", "remove", "non_existent_section_12345"])
|
||||||
|
assert res.returncode != 0
|
||||||
|
assert "not found" in res.stderr
|
||||||
Loading…
Add table
Add a link
Reference in a new issue