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:
|
if res.returncode == 0:
|
||||||
raise ValueError(f"MAC {mac} already exists")
|
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()
|
self.reload()
|
||||||
|
|
||||||
def remove_host(self, mac):
|
def remove_host(self, mac):
|
||||||
|
|
@ -30,11 +31,13 @@ class DNSManager:
|
||||||
|
|
||||||
def add_dns(self, domain, ip):
|
def add_dns(self, domain, ip):
|
||||||
self.exec_lxc(f"touch {self.dns_file}")
|
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()
|
self.reload()
|
||||||
|
|
||||||
def remove_dns(self, domain):
|
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()
|
self.reload()
|
||||||
|
|
||||||
def reload(self):
|
def reload(self):
|
||||||
|
|
|
||||||
|
|
@ -63,11 +63,45 @@ def ingress_add(config, domain, ip, port, https):
|
||||||
mgr.add(domain, ip, port, https)
|
mgr.add(domain, ip, port, https)
|
||||||
click.echo(f"Added ingress for {domain}")
|
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()
|
@cli.group()
|
||||||
def router():
|
def router():
|
||||||
"""Manage Router Port Forwards"""
|
"""Manage Router Port Forwards"""
|
||||||
pass
|
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')
|
@router.command(name='list')
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def router_list(config):
|
def router_list(config):
|
||||||
|
|
|
||||||
|
|
@ -38,16 +38,17 @@ class RouterManager:
|
||||||
|
|
||||||
def list(self):
|
def list(self):
|
||||||
# Get sections
|
# 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")
|
res = self.client.run("uci show firewall | grep '=redirect' | cut -d. -f2 | cut -d= -f1 | sort | uniq")
|
||||||
sections = res.stdout.strip().split('\n')
|
sections = res.stdout.strip().split('\n')
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for section in sections:
|
for section in sections:
|
||||||
if not section: continue
|
if not section: continue
|
||||||
name = self.client.run(f"uci get firewall.{section}.name", 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", capture=True).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", capture=True).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", capture=True).stdout.strip()
|
dest = self.client.run(f"uci get firewall.{section}.dest_ip").stdout.strip()
|
||||||
results.append({
|
results.append({
|
||||||
"section": section,
|
"section": section,
|
||||||
"name": name,
|
"name": name,
|
||||||
|
|
|
||||||
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
|
# Remove
|
||||||
res = run_infra(["ingress", "remove", domain])
|
res = run_infra(["ingress", "remove", domain])
|
||||||
assert res.returncode == 0
|
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