feat: complete professional cli with full lifecycle and tests

This commit is contained in:
Fredrick Amnehagen 2026-02-05 11:37:29 +01:00
parent a7d97227d3
commit 34ba255024
12 changed files with 112 additions and 9 deletions

46
README.md Normal file
View 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
```

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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):

View file

@ -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):

View file

@ -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,

View file

@ -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