feat: complete professional cli with full lifecycle and tests
This commit is contained in:
parent
a7d97227d3
commit
34ba255024
12 changed files with 112 additions and 9 deletions
46
README.md
Normal file
46
README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# LoopAware Infrastructure CLI
|
||||
|
||||
Professional CLI tooling for managing dynamic infrastructure resources (DNS, DHCP, Ingress, and Port Forwarding).
|
||||
|
||||
## 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.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd external/dynamic-infra-tooling
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `config.yaml.example` to `config.yaml` and update with your infrastructure details.
|
||||
|
||||
```bash
|
||||
cp config.yaml.example config.yaml
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Use the `infra` command:
|
||||
|
||||
```bash
|
||||
# List DNS entries
|
||||
infra dns list
|
||||
|
||||
# Add an ingress
|
||||
infra ingress add my-app.loopaware.com 10.32.70.50 80
|
||||
|
||||
# Add a port forward (requires ROUTER_PASS env var)
|
||||
export ROUTER_PASS='...'
|
||||
infra router add "My-Service" tcp 5000 10.32.70.50 5000
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
pytest tests/test_cli.py
|
||||
```
|
||||
BIN
infra_cli/__pycache__/config.cpython-313.pyc
Normal file
BIN
infra_cli/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
infra_cli/__pycache__/dns.cpython-313.pyc
Normal file
BIN
infra_cli/__pycache__/dns.cpython-313.pyc
Normal file
Binary file not shown.
BIN
infra_cli/__pycache__/ingress.cpython-313.pyc
Normal file
BIN
infra_cli/__pycache__/ingress.cpython-313.pyc
Normal file
Binary file not shown.
BIN
infra_cli/__pycache__/main.cpython-313.pyc
Normal file
BIN
infra_cli/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
infra_cli/__pycache__/router.cpython-313.pyc
Normal file
BIN
infra_cli/__pycache__/router.cpython-313.pyc
Normal file
Binary file not shown.
BIN
infra_cli/__pycache__/ssh.cpython-313.pyc
Normal file
BIN
infra_cli/__pycache__/ssh.cpython-313.pyc
Normal file
Binary file not shown.
|
|
@ -21,7 +21,8 @@ class DNSManager:
|
|||
if res.returncode == 0:
|
||||
raise ValueError(f"MAC {mac} already exists")
|
||||
|
||||
self.client.run(f"pct exec {self.lxc_id} -- sh -c \"echo 'dhcp-host={mac},{hostname},{ip}' >> {self.hosts_file}\")
|
||||
cmd = f"sh -c \"echo 'dhcp-host={mac},{hostname},{ip}' >> {self.hosts_file}\""
|
||||
self.exec_lxc(cmd)
|
||||
self.reload()
|
||||
|
||||
def remove_host(self, mac):
|
||||
|
|
@ -30,11 +31,13 @@ class DNSManager:
|
|||
|
||||
def add_dns(self, domain, ip):
|
||||
self.exec_lxc(f"touch {self.dns_file}")
|
||||
self.client.run(f"pct exec {self.lxc_id} -- sh -c \"echo 'address=/{domain}/{ip}' >> {self.dns_file}\")
|
||||
cmd = f"sh -c \"echo 'address=/{domain}/{ip}' >> {self.dns_file}\""
|
||||
self.exec_lxc(cmd)
|
||||
self.reload()
|
||||
|
||||
def remove_dns(self, domain):
|
||||
self.client.run(f"pct exec {self.lxc_id} -- sh -c \"sed -i '\#address=/{domain}/#d' {self.dns_file}\")
|
||||
cmd = f"sh -c \"sed -i '\#address=/{domain}/#d' {self.dns_file}\""
|
||||
self.exec_lxc(cmd)
|
||||
self.reload()
|
||||
|
||||
def reload(self):
|
||||
|
|
@ -47,4 +50,4 @@ class DNSManager:
|
|||
def list(self):
|
||||
hosts = self.exec_lxc(f"cat {self.hosts_file}").stdout
|
||||
dns = self.exec_lxc(f"cat {self.dns_file}").stdout
|
||||
return {"hosts": hosts, "dns": dns}
|
||||
return {"hosts": hosts, "dns": dns}
|
||||
|
|
@ -63,11 +63,45 @@ def ingress_add(config, domain, ip, port, https):
|
|||
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):
|
||||
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)
|
||||
mgr.remove_forward(section)
|
||||
click.echo(f"Removed port forward {section}")
|
||||
|
||||
@router.command(name='list')
|
||||
@click.pass_obj
|
||||
def router_list(config):
|
||||
|
|
|
|||
|
|
@ -38,16 +38,17 @@ class RouterManager:
|
|||
|
||||
def list(self):
|
||||
# Get sections
|
||||
# Use -n to prevent SSH from consuming stdin if we were in a loop (here we use split)
|
||||
res = self.client.run("uci show firewall | grep '=redirect' | cut -d. -f2 | cut -d= -f1 | sort | uniq")
|
||||
sections = res.stdout.strip().split('\n')
|
||||
|
||||
results = []
|
||||
for section in sections:
|
||||
if not section: continue
|
||||
name = self.client.run(f"uci get firewall.{section}.name", capture=True).stdout.strip()
|
||||
proto = self.client.run(f"uci get firewall.{section}.proto", capture=True).stdout.strip()
|
||||
port = self.client.run(f"uci get firewall.{section}.src_dport", capture=True).stdout.strip()
|
||||
dest = self.client.run(f"uci get firewall.{section}.dest_ip", capture=True).stdout.strip()
|
||||
name = self.client.run(f"uci get firewall.{section}.name").stdout.strip()
|
||||
proto = self.client.run(f"uci get firewall.{section}.proto").stdout.strip()
|
||||
port = self.client.run(f"uci get firewall.{section}.src_dport").stdout.strip()
|
||||
dest = self.client.run(f"uci get firewall.{section}.dest_ip").stdout.strip()
|
||||
results.append({
|
||||
"section": section,
|
||||
"name": name,
|
||||
|
|
@ -55,4 +56,4 @@ class RouterManager:
|
|||
"port": port,
|
||||
"dest": dest
|
||||
})
|
||||
return results
|
||||
return results
|
||||
BIN
tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
|
|
@ -43,3 +43,22 @@ def test_ingress_cli(unique_id):
|
|||
# Remove
|
||||
res = run_infra(["ingress", "remove", domain])
|
||||
assert res.returncode == 0
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue