Initial commit of AthonetTools
This commit is contained in:
BIN
services/__pycache__/combocore.cpython-310.pyc
Normal file
BIN
services/__pycache__/combocore.cpython-310.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/local_net.cpython-310.pyc
Normal file
BIN
services/__pycache__/local_net.cpython-310.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/net_info.cpython-310.pyc
Normal file
BIN
services/__pycache__/net_info.cpython-310.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/remote_admin.cpython-310.pyc
Normal file
BIN
services/__pycache__/remote_admin.cpython-310.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/state.cpython-310.pyc
Normal file
BIN
services/__pycache__/state.cpython-310.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/yaml_writer.cpython-310.pyc
Normal file
BIN
services/__pycache__/yaml_writer.cpython-310.pyc
Normal file
Binary file not shown.
2
services/api/cc_v1/init.py
Normal file
2
services/api/cc_v1/init.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from flask import Blueprint
|
||||
bp = Blueprint("cc_v1", __name__)
|
||||
25
services/api/cc_v1/ncm.py
Normal file
25
services/api/cc_v1/ncm.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from services.combocore.ncm import get_routes, derive_eth0_cidr_gw
|
||||
|
||||
bp = Blueprint("cc_ncm_v1", __name__)
|
||||
|
||||
@bp.post("/routes")
|
||||
def api_routes():
|
||||
body = request.get_json(silent=True) or {}
|
||||
host = body.get("host", "").strip()
|
||||
if not host:
|
||||
return jsonify({"ok": False, "error": "host required"}), 400
|
||||
cfg = current_app.config
|
||||
routes = get_routes(host, cfg["CORE_API_USER"], cfg["CORE_API_PASS"], cfg["VERIFY_SSL"], cfg["REQUEST_TIMEOUT"])
|
||||
return jsonify({"ok": True, "routes": routes})
|
||||
|
||||
@bp.post("/oam")
|
||||
def api_oam_eth0():
|
||||
body = request.get_json(silent=True) or {}
|
||||
host = body.get("host", "").strip()
|
||||
if not host:
|
||||
return jsonify({"ok": False, "error": "host required"}), 400
|
||||
cfg = current_app.config
|
||||
routes = get_routes(host, cfg["CORE_API_USER"], cfg["CORE_API_PASS"], cfg["VERIFY_SSL"], cfg["REQUEST_TIMEOUT"])
|
||||
cidr, gw = derive_eth0_cidr_gw(routes)
|
||||
return jsonify({"ok": True, "cidr": cidr, "gw": gw})
|
||||
24
services/api/cc_v1/pls.py
Normal file
24
services/api/cc_v1/pls.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from services.combocore.pls import login as pls_login, get_me
|
||||
|
||||
bp = Blueprint("cc_pls_v1", __name__)
|
||||
|
||||
@bp.post("/login")
|
||||
def api_login():
|
||||
body = request.get_json(silent=True) or {}
|
||||
host = body.get("host", "").strip()
|
||||
if not host:
|
||||
return jsonify({"ok": False, "error": "host required"}), 400
|
||||
cfg = current_app.config
|
||||
token = pls_login(host, cfg["CORE_API_USER"], cfg["CORE_API_PASS"], cfg["VERIFY_SSL"], cfg["REQUEST_TIMEOUT"])
|
||||
return jsonify({"ok": True, "access_token": token})
|
||||
|
||||
@bp.post("/me")
|
||||
def api_me():
|
||||
body = request.get_json(silent=True) or {}
|
||||
host = body.get("host", "").strip()
|
||||
if not host:
|
||||
return jsonify({"ok": False, "error": "host required"}), 400
|
||||
cfg = current_app.config
|
||||
me = get_me(host, cfg["CORE_API_USER"], cfg["CORE_API_PASS"], cfg["VERIFY_SSL"], cfg["REQUEST_TIMEOUT"])
|
||||
return jsonify({"ok": True, "me": me})
|
||||
63
services/combocore.py
Normal file
63
services/combocore.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# services/combocore.py
|
||||
import requests
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
requests.packages.urllib3.disable_warnings()
|
||||
|
||||
def _fmt_host(host: str) -> str:
|
||||
host = host.strip().strip("[]")
|
||||
# if IPv6, wrap with []
|
||||
if ":" in host and not host.startswith("["):
|
||||
return f"[{host}]"
|
||||
return host
|
||||
|
||||
def login(host: str, username="admin", password="Super4dmin!") -> str:
|
||||
h = _fmt_host(host)
|
||||
url = f"https://{h}/core/pls/api/1/auth/login"
|
||||
r = requests.post(url, json={"username": username, "password": password}, verify=False, timeout=10)
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
# API returns "access_token"
|
||||
return j["access_token"]
|
||||
|
||||
def get_routes(host: str, token: str) -> list:
|
||||
h = _fmt_host(host)
|
||||
url = f"https://{h}/core/ncm/api/1/status/routes"
|
||||
r = requests.get(url, headers={"Authorization": f"Bearer {token}"}, verify=False, timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def extract_eth0_cidr_gw(routes: list) -> tuple[str, str]:
|
||||
"""
|
||||
From the routes list:
|
||||
- CIDR = <prefsrc>/<mask-from-eth0-connected-route>
|
||||
- GW = default route gateway on eth0
|
||||
"""
|
||||
prefsrc = None
|
||||
mask = None
|
||||
gw = None
|
||||
|
||||
# default gw via eth0
|
||||
for r in routes:
|
||||
if r.get("family") == "inet" and r.get("type") == "unicast" and r.get("dst") == "default" and r.get("dev") == "eth0":
|
||||
gw = r.get("gateway")
|
||||
break
|
||||
|
||||
# find eth0 connected network to derive mask, and prefsrc for IP
|
||||
for r in routes:
|
||||
if r.get("family") == "inet" and r.get("dev") == "eth0":
|
||||
if not prefsrc and r.get("prefsrc"):
|
||||
prefsrc = r.get("prefsrc")
|
||||
dst = r.get("dst") or ""
|
||||
if "/" in dst:
|
||||
try:
|
||||
mask = dst.split("/", 1)[1]
|
||||
except Exception:
|
||||
pass
|
||||
if prefsrc and mask and gw:
|
||||
break
|
||||
|
||||
if not (prefsrc and mask and gw):
|
||||
raise ValueError("Unable to derive eth0 CIDR/GW from routes payload")
|
||||
|
||||
return f"{prefsrc}/{mask}", gw
|
||||
54
services/combocore/client.py
Normal file
54
services/combocore/client.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
class ComboCoreClient:
|
||||
def __init__(self, host: str, username: str, password: str, verify_ssl: bool = False, timeout: float = DEFAULT_TIMEOUT):
|
||||
self.base = f"https://{host}"
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.verify = verify_ssl
|
||||
self.timeout = timeout
|
||||
self._token = None
|
||||
self._s = requests.Session()
|
||||
retry = Retry(total=3, backoff_factor=0.3, status_forcelist=[429,500,502,503,504])
|
||||
self._s.mount("https://", HTTPAdapter(max_retries=retry))
|
||||
self._s.mount("http://", HTTPAdapter(max_retries=retry))
|
||||
|
||||
# ----- PLS auth -----
|
||||
def login(self) -> str:
|
||||
r = self._s.post(
|
||||
f"{self.base}/core/pls/api/1/auth/login",
|
||||
json={"username": self.username, "password": self.password},
|
||||
timeout=self.timeout, verify=self.verify
|
||||
)
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
self._token = j.get("access_token") or j.get("token")
|
||||
if not self._token:
|
||||
raise RuntimeError("No access token in login response.")
|
||||
return self._token
|
||||
|
||||
def _auth_headers(self):
|
||||
if not self._token:
|
||||
self.login()
|
||||
return {"Authorization": f"Bearer {self._token}"}
|
||||
|
||||
def get(self, path: str):
|
||||
r = self._s.get(f"{self.base}{path}", headers=self._auth_headers(), timeout=self.timeout, verify=self.verify)
|
||||
if r.status_code == 401:
|
||||
self._token = None
|
||||
r = self._s.get(f"{self.base}{path}", headers=self._auth_headers(), timeout=self.timeout, verify=self.verify)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def post(self, path: str, json=None):
|
||||
r = self._s.post(f"{self.base}{path}", headers=self._auth_headers(), json=json or {}, timeout=self.timeout, verify=self.verify)
|
||||
if r.status_code == 401:
|
||||
self._token = None
|
||||
r = self._s.post(f"{self.base}{path}", headers=self._auth_headers(), json=json or {}, timeout=self.timeout, verify=self.verify)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
29
services/combocore/ncm.py
Normal file
29
services/combocore/ncm.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from typing import Tuple, Optional, List, Dict
|
||||
from .client import ComboCoreClient
|
||||
|
||||
def get_routes(host: str, user: str, pwd: str, verify_ssl=False, timeout=10.0) -> List[Dict]:
|
||||
cli = ComboCoreClient(host, user, pwd, verify_ssl, timeout)
|
||||
return cli.get("/core/ncm/api/1/status/routes")
|
||||
|
||||
def derive_eth0_cidr_gw(routes: List[Dict]) -> Tuple[str, Optional[str]]:
|
||||
gw = None
|
||||
ip = None
|
||||
masklen = None
|
||||
for rt in routes:
|
||||
if rt.get("family") == "inet" and rt.get("dst") == "default" and rt.get("dev") == "eth0":
|
||||
gw = rt.get("gateway")
|
||||
for rt in routes:
|
||||
if rt.get("family") == "inet" and rt.get("dev") == "eth0":
|
||||
if not ip and rt.get("prefsrc"):
|
||||
ip = rt["prefsrc"]
|
||||
dst = rt.get("dst", "")
|
||||
if "/" in dst:
|
||||
try:
|
||||
masklen = int(dst.split("/", 1)[1])
|
||||
except Exception:
|
||||
pass
|
||||
if ip and masklen is not None:
|
||||
break
|
||||
if not ip or masklen is None:
|
||||
raise RuntimeError("Could not derive eth0 IP/prefix from routes.")
|
||||
return f"{ip}/{masklen}", gw
|
||||
9
services/combocore/pls.py
Normal file
9
services/combocore/pls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .client import ComboCoreClient
|
||||
|
||||
# Minimal PLS surface to start
|
||||
def login(host: str, user: str, pwd: str, verify_ssl=False, timeout=10.0) -> str:
|
||||
return ComboCoreClient(host, user, pwd, verify_ssl, timeout).login()
|
||||
|
||||
def get_me(host: str, user: str, pwd: str, verify_ssl=False, timeout=10.0) -> dict:
|
||||
cli = ComboCoreClient(host, user, pwd, verify_ssl, timeout)
|
||||
return cli.get("/core/pls/api/1/auth/me") # adjust if different
|
||||
42
services/local_net.py
Normal file
42
services/local_net.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# services/local_net.py
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
def _sh(cmd):
|
||||
out = subprocess.check_output(cmd, text=True)
|
||||
return out
|
||||
|
||||
def get_eth0_dhcp_snapshot():
|
||||
"""
|
||||
Returns {'iface':'eth0','cidr':'X.X.X.X/YY','ip':'X.X.X.X','prefixlen':YY,'gw':'A.B.C.D'}
|
||||
Raises on failure.
|
||||
"""
|
||||
iface = "eth0"
|
||||
|
||||
# ip -o -4 addr show dev eth0 | awk '{print $4}'
|
||||
addrs = _sh(["/sbin/ip", "-o", "-4", "addr", "show", "dev", iface]).strip()
|
||||
if not addrs:
|
||||
addrs = _sh(["ip", "-o", "-4", "addr", "show", "dev", iface]).strip()
|
||||
|
||||
m = re.search(r"\s(\d+\.\d+\.\d+\.\d+/\d+)\s", addrs)
|
||||
if not m:
|
||||
raise RuntimeError(f"No IPv4 address on {iface}")
|
||||
cidr = m.group(1)
|
||||
ip = cidr.split("/")[0]
|
||||
prefixlen = int(cidr.split("/")[1])
|
||||
|
||||
# ip route show default dev eth0 | awk '/default/ {print $3}'
|
||||
routes = _sh(["/sbin/ip", "route", "show", "default", "dev", iface])
|
||||
if not routes:
|
||||
routes = _sh(["ip", "route", "show", "default", "dev", iface])
|
||||
|
||||
m2 = re.search(r"default via (\d+\.\d+\.\d+\.\d+)", routes)
|
||||
gw = m2.group(1) if m2 else ""
|
||||
|
||||
return {
|
||||
"iface": iface,
|
||||
"cidr": cidr,
|
||||
"ip": ip,
|
||||
"prefixlen": prefixlen,
|
||||
"gw": gw
|
||||
}
|
||||
43
services/net_info.py
Normal file
43
services/net_info.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# services/net_info.py
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
def _run(cmd: list[str]) -> str:
|
||||
return subprocess.check_output(cmd, text=True).strip()
|
||||
|
||||
def _first_ipv4_cidr_from_ip_json(ip_json: dict) -> str | None:
|
||||
# Find first IPv4 on the interface
|
||||
for addr in ip_json.get("addr_info", []):
|
||||
if addr.get("family") == "inet" and addr.get("local") and addr.get("prefixlen") is not None:
|
||||
return f'{addr["local"]}/{addr["prefixlen"]}'
|
||||
return None
|
||||
|
||||
def get_iface_cidr(iface: str) -> str | None:
|
||||
# ip -j addr show dev eth0
|
||||
raw = _run(["ip", "-j", "addr", "show", "dev", iface])
|
||||
data = json.loads(raw)
|
||||
if not data:
|
||||
return None
|
||||
return _first_ipv4_cidr_from_ip_json(data[0])
|
||||
|
||||
def get_default_gw_for_iface(iface: str) -> str | None:
|
||||
lines = _run(["ip", "route", "show", "default", "dev", iface]).splitlines()
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if parts and parts[0] == "default":
|
||||
try:
|
||||
idx = parts.index("via")
|
||||
return parts[idx + 1]
|
||||
except ValueError:
|
||||
continue
|
||||
except IndexError:
|
||||
continue
|
||||
return None
|
||||
|
||||
def get_eth0_dhcp_snapshot() -> dict:
|
||||
iface = "eth0"
|
||||
cidr = get_iface_cidr(iface) or ""
|
||||
gw = get_default_gw_for_iface(iface) or ""
|
||||
if not cidr or not gw:
|
||||
raise RuntimeError("Could not determine eth0 CIDR and/or default gateway")
|
||||
return {"iface": iface, "cidr": cidr, "gw": gw}
|
||||
48
services/remote_admin.py
Normal file
48
services/remote_admin.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# services/remote_admin.py
|
||||
import requests, ipaddress
|
||||
|
||||
REQUEST_KW = dict(timeout=10, verify=False)
|
||||
|
||||
def validate_ipv4(ip: str) -> str:
|
||||
try:
|
||||
ipaddress.IPv4Address(ip)
|
||||
return ip
|
||||
except Exception:
|
||||
raise ValueError(f"Invalid IPv4 address: {ip}")
|
||||
|
||||
def authenticate(ip: str, username: str, password: str) -> str:
|
||||
url = f"https://{ip}/core/pls/api/1/auth/login"
|
||||
r = requests.post(url, json={"username": username, "password": password}, **REQUEST_KW)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
token = data.get("access_token") or data.get("token") or data.get("data", {}).get("access_token")
|
||||
if not token:
|
||||
raise RuntimeError("No access token in auth response")
|
||||
return token
|
||||
|
||||
ALLOWED_SERVICES = {"ssh", "webconsole"}
|
||||
ALLOWED_ACTIONS = {"enable", "enable-autostart", "start"}
|
||||
|
||||
def service_action(ip: str, service: str, action: str, username: str, password: str) -> dict:
|
||||
if service not in ALLOWED_SERVICES:
|
||||
raise ValueError(f"Unsupported service: {service}")
|
||||
if action not in ALLOWED_ACTIONS:
|
||||
raise ValueError(f"Unsupported action: {action}")
|
||||
|
||||
ip = validate_ipv4(ip)
|
||||
token = authenticate(ip, username, password)
|
||||
url = f"https://{ip}/core/pls/api/1/services/{service}/{action}"
|
||||
r = requests.post(url, headers={"Authorization": f"Bearer {token}"}, **REQUEST_KW)
|
||||
r.raise_for_status()
|
||||
return r.json() if r.content else {"ok": True}
|
||||
|
||||
def perform_service_sequence(ip: str, service: str, username: str, password: str) -> None:
|
||||
for action in ("enable", "enable-autostart", "start"):
|
||||
service_action(ip, service, action, username, password)
|
||||
|
||||
# Backwards-compatible wrappers (optional)
|
||||
def ssh_action(ip, action, username, password):
|
||||
return service_action(ip, "ssh", action, username, password)
|
||||
|
||||
def perform_ssh_sequence(ip, username, password):
|
||||
return perform_service_sequence(ip, "ssh", username, password)
|
||||
31
services/state.py
Normal file
31
services/state.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# services/state.py
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
STATE_FILE = Path("system_info.json")
|
||||
|
||||
def load_state():
|
||||
if STATE_FILE.exists():
|
||||
return json.loads(STATE_FILE.read_text() or "{}")
|
||||
return {}
|
||||
|
||||
def save_state(d: dict):
|
||||
STATE_FILE.write_text(json.dumps(d, indent=2))
|
||||
|
||||
def set_target_ip(ip: str):
|
||||
st = load_state()
|
||||
st["target_host_ip"] = ip
|
||||
save_state(st)
|
||||
|
||||
def get_target_ip() -> str | None:
|
||||
return load_state().get("target_host_ip")
|
||||
|
||||
def set_mgmt_info(cidr: str, gw: str):
|
||||
"""Persist the current DHCP values we discovered for eth0 so we can render static OAM later."""
|
||||
st = load_state()
|
||||
st["mgmt"] = {"cidr": cidr, "gw": gw}
|
||||
save_state(st)
|
||||
|
||||
def get_mgmt_info() -> dict:
|
||||
"""Return {'cidr': 'x.x.x.x/yy', 'gw': 'x.x.x.x'} if previously captured, else {}."""
|
||||
return load_state().get("mgmt") or {}
|
||||
52
services/yaml_writer.py
Normal file
52
services/yaml_writer.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# services/yaml_writer.py
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
import shutil
|
||||
|
||||
ANSIBLE_ROOT = Path("ansible_workspace")
|
||||
STAGING = ANSIBLE_ROOT / "staging"
|
||||
TEMPLATE_DIR = Path("templates/ansible_templates")
|
||||
STATIC_SEEDS = TEMPLATE_DIR / "_seeds" # optional: for csv/license seed files
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
||||
autoescape=select_autoescape(enabled_extensions=()),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
def clean_dir(p: Path):
|
||||
if p.exists():
|
||||
for item in sorted(p.rglob("*"), reverse=True):
|
||||
if item.is_file():
|
||||
item.unlink()
|
||||
elif item.is_dir():
|
||||
item.rmdir()
|
||||
|
||||
def ensure_tree(scenario: str, hostname: str, esxi_host: str) -> Path:
|
||||
"""
|
||||
Returns the base path to .../staging/<scenario>/ and ensures the tree exists.
|
||||
"""
|
||||
base = STAGING / scenario
|
||||
(base / "host_vars" / hostname).mkdir(parents=True, exist_ok=True)
|
||||
(base / "host_vars" / esxi_host).mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
def render_to_file(template_name: str, context: Dict[str, Any], out_path: Path):
|
||||
print(f"[DEBUG] Rendering {out_path} with context: {context}")
|
||||
tmpl = env.get_template(template_name)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(tmpl.render(**context))
|
||||
|
||||
def copy_seed(name: str, dest: Path):
|
||||
"""
|
||||
Copies a static seed file (e.g., impus.csv, supis.csv, license_vars.yaml)
|
||||
from templates/ansible_templates/_seeds/ if present; otherwise creates empty.
|
||||
"""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
seed = STATIC_SEEDS / name
|
||||
if seed.exists():
|
||||
shutil.copyfile(seed, dest)
|
||||
else:
|
||||
dest.write_text("") # empty default
|
||||
Reference in New Issue
Block a user