feat: implement device provisioning with app update, service management and reboot
This commit is contained in:
189
src/provision.py
189
src/provision.py
@@ -21,17 +21,6 @@ SSH_PORT = int(os.getenv("IOT_SSH_PORT", "22"))
|
|||||||
SSH_KEY = os.getenv("SSH_KEY") or None # path or None
|
SSH_KEY = os.getenv("SSH_KEY") or None # path or None
|
||||||
PROVISION_LOG = os.getenv("PROVISION_LOG") or str((Path(__file__).resolve().parent / "provision.log"))
|
PROVISION_LOG = os.getenv("PROVISION_LOG") or str((Path(__file__).resolve().parent / "provision.log"))
|
||||||
|
|
||||||
def _get(obj, *names):
|
|
||||||
"""Safe attribute/dict getter trying several candidate names."""
|
|
||||||
for n in names:
|
|
||||||
if hasattr(obj, n):
|
|
||||||
return getattr(obj, n)
|
|
||||||
if isinstance(obj, dict) and n in obj:
|
|
||||||
return obj[n]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def scp_and_enable(ssh_host, config_text):
|
def scp_and_enable(ssh_host, config_text):
|
||||||
tmp = Path(tempfile.gettempdir()) / f"{WG_IFACE}.conf"
|
tmp = Path(tempfile.gettempdir()) / f"{WG_IFACE}.conf"
|
||||||
@@ -47,9 +36,11 @@ def scp_and_enable(ssh_host, config_text):
|
|||||||
sudo mkdir -p /etc/wireguard
|
sudo mkdir -p /etc/wireguard
|
||||||
sudo install -m 600 /tmp/{WG_IFACE}.conf /etc/wireguard/{WG_IFACE}.conf
|
sudo install -m 600 /tmp/{WG_IFACE}.conf /etc/wireguard/{WG_IFACE}.conf
|
||||||
# If interface exists already, bring it down first to reload config cleanly
|
# If interface exists already, bring it down first to reload config cleanly
|
||||||
sudo wg-quick down {WG_IFACE} || true
|
sudo systemctl stop wg-quick@{WG_IFACE} || sudo wg-quick down {WG_IFACE} || true
|
||||||
|
sudo ip link del {WG_IFACE} || true
|
||||||
|
sudo systemctl reset-failed wg-quick@{WG_IFACE} || true
|
||||||
|
sudo systemctl daemon-reload || true
|
||||||
# Bring interface up and ensure service is enabled+active
|
# Bring interface up and ensure service is enabled+active
|
||||||
sudo wg-quick up {WG_IFACE} || true
|
|
||||||
sudo systemctl enable --now wg-quick@{WG_IFACE}
|
sudo systemctl enable --now wg-quick@{WG_IFACE}
|
||||||
sudo systemctl is-enabled wg-quick@{WG_IFACE} || true
|
sudo systemctl is-enabled wg-quick@{WG_IFACE} || true
|
||||||
sudo systemctl is-active wg-quick@{WG_IFACE} || true
|
sudo systemctl is-active wg-quick@{WG_IFACE} || true
|
||||||
@@ -123,7 +114,6 @@ def step_set_hostname(iot_host: str, hostname: str | None):
|
|||||||
# Known locations where the script might live on the device
|
# Known locations where the script might live on the device
|
||||||
candidates = [
|
candidates = [
|
||||||
"/home/caster/bumble-auracast/src/auracast/server/provision_domain_hostname.sh",
|
"/home/caster/bumble-auracast/src/auracast/server/provision_domain_hostname.sh",
|
||||||
"/home/caster/bumble-auracast/src/server/provision_domain_hostname.sh",
|
|
||||||
]
|
]
|
||||||
domain = "local"
|
domain = "local"
|
||||||
|
|
||||||
@@ -172,16 +162,38 @@ def step_set_hostname(iot_host: str, hostname: str | None):
|
|||||||
"err": stderr[-500:],
|
"err": stderr[-500:],
|
||||||
}
|
}
|
||||||
|
|
||||||
def step_update_app(iot_host: str, services: list[str] | None = None):
|
def step_update_app(iot_host: str):
|
||||||
"""Placeholder: start/enable required system services on the device.
|
"""Placeholder: start/enable required system services on the device.
|
||||||
|
|
||||||
Intention: SSH into the device and run systemctl enable --now <service> for required services.
|
Intention: SSH into the device and run systemctl enable --now <service> for required services.
|
||||||
Currently does nothing.
|
Currently does nothing.
|
||||||
"""
|
"""
|
||||||
# TODO: run the app update script
|
remote = (
|
||||||
services = services or []
|
"set -e\n"
|
||||||
print("⏭️ [placeholder] Skipping start services (no-op).")
|
"cd ~/bumble-auracast\n"
|
||||||
return {"services": services}
|
"git pull\n"
|
||||||
|
"/home/caster/.local/bin/poetry config virtualenvs.in-project true\n"
|
||||||
|
"/home/caster/.local/bin/poetry install\n"
|
||||||
|
)
|
||||||
|
ssh_cmd = ["ssh", "-p", str(SSH_PORT)]
|
||||||
|
if SSH_KEY:
|
||||||
|
ssh_cmd += ["-i", SSH_KEY]
|
||||||
|
ssh_cmd += [f"{SSH_USER}@{iot_host}", remote]
|
||||||
|
|
||||||
|
proc = subprocess.run(ssh_cmd, check=False, capture_output=True, text=True)
|
||||||
|
stdout = (proc.stdout or "").strip()
|
||||||
|
stderr = (proc.stderr or "").strip()
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(f"❌ update app: failed rc={proc.returncode}: {stderr}")
|
||||||
|
else:
|
||||||
|
print("✅ update app: poetry install completed")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rc": proc.returncode,
|
||||||
|
"out": stdout[-500:],
|
||||||
|
"err": stderr[-500:],
|
||||||
|
}
|
||||||
|
|
||||||
def step_start_app(iot_host: str, app: str):
|
def step_start_app(iot_host: str, app: str):
|
||||||
"""Placeholder: start the application flow (script or UI) on the device.
|
"""Placeholder: start the application flow (script or UI) on the device.
|
||||||
@@ -189,9 +201,90 @@ def step_start_app(iot_host: str, app: str):
|
|||||||
Intention: depending on --app, run an app launcher over SSH.
|
Intention: depending on --app, run an app launcher over SSH.
|
||||||
Currently does nothing.
|
Currently does nothing.
|
||||||
"""
|
"""
|
||||||
# TODO: start with pulling the latest main from the repo
|
scripts = [
|
||||||
print(f"⏭️ [placeholder] Skipping start app (no-op). --app={app}")
|
"update_and_run_pw_aes67.sh",
|
||||||
return {"app_mode": app}
|
"update_and_run_server_and_frontend.sh",
|
||||||
|
]
|
||||||
|
remote = (
|
||||||
|
"set -e\n"
|
||||||
|
"loginctl enable-linger \"$USER\"\n"
|
||||||
|
"cd ~/bumble-auracast\n"
|
||||||
|
"for s in " + " ".join(shlex.quote(s) for s in scripts) + "; do\n"
|
||||||
|
" if [ -f \"$s\" ]; then\n"
|
||||||
|
" echo \"▶ running $s\"\n"
|
||||||
|
" bash \"$s\"\n"
|
||||||
|
" else\n"
|
||||||
|
" echo \"⚠️ missing $s\" 1>&2\n"
|
||||||
|
" fi\n"
|
||||||
|
"done\n"
|
||||||
|
"# Ensure services are enabled and started\n"
|
||||||
|
"systemctl --user daemon-reload\n"
|
||||||
|
"systemctl --user enable --now auracast-server.service\n"
|
||||||
|
"sudo systemctl daemon-reload\n"
|
||||||
|
"sudo systemctl enable --now auracast-frontend.service\n"
|
||||||
|
"systemctl --user is-enabled auracast-server.service || true\n"
|
||||||
|
"systemctl --user is-active auracast-server.service || true\n"
|
||||||
|
"sudo systemctl is-enabled auracast-frontend.service || true\n"
|
||||||
|
"sudo systemctl is-active auracast-frontend.service || true\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
ssh_cmd = ["ssh", "-p", str(SSH_PORT)]
|
||||||
|
if SSH_KEY:
|
||||||
|
ssh_cmd += ["-i", SSH_KEY]
|
||||||
|
ssh_cmd += [f"{SSH_USER}@{iot_host}", remote]
|
||||||
|
|
||||||
|
proc = subprocess.run(ssh_cmd, check=False, capture_output=True, text=True)
|
||||||
|
stdout = (proc.stdout or "").strip()
|
||||||
|
stderr = (proc.stderr or "").strip()
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(f"❌ start app: failed rc={proc.returncode}")
|
||||||
|
if stderr:
|
||||||
|
print(f"stderr: {stderr[-1000:]}")
|
||||||
|
if stdout:
|
||||||
|
print(f"stdout: {stdout[-1000:]}")
|
||||||
|
else:
|
||||||
|
print("✅ start app: scripts executed")
|
||||||
|
if stdout:
|
||||||
|
print(f"Output:\n{stdout[-1000:]}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"app_mode": app,
|
||||||
|
"rc": proc.returncode,
|
||||||
|
"out": stdout[-1000:],
|
||||||
|
"err": stderr[-1000:],
|
||||||
|
}
|
||||||
|
|
||||||
|
def step_finish(iot_host: str):
|
||||||
|
"""Finalize setup on the device: enable linger for the user and reboot.
|
||||||
|
|
||||||
|
Executes on remote:
|
||||||
|
- loginctl enable-linger "$USER"
|
||||||
|
- sudo reboot
|
||||||
|
"""
|
||||||
|
remote = (
|
||||||
|
"set -e\n"
|
||||||
|
"sudo reboot\n"
|
||||||
|
)
|
||||||
|
ssh_cmd = ["ssh", "-p", str(SSH_PORT)]
|
||||||
|
if SSH_KEY:
|
||||||
|
ssh_cmd += ["-i", SSH_KEY]
|
||||||
|
ssh_cmd += [f"{SSH_USER}@{iot_host}", remote]
|
||||||
|
|
||||||
|
proc = subprocess.run(ssh_cmd, check=False, capture_output=True, text=True)
|
||||||
|
stdout = (proc.stdout or "").strip()
|
||||||
|
stderr = (proc.stderr or "").strip()
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(f"❌ finish: failed rc={proc.returncode}: {stderr}")
|
||||||
|
else:
|
||||||
|
print("✅ finish: reboot initiated")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rc": proc.returncode,
|
||||||
|
"out": stdout[-500:],
|
||||||
|
"err": stderr[-500:],
|
||||||
|
}
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
import argparse
|
import argparse
|
||||||
@@ -202,40 +295,40 @@ def main():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
ap.add_argument("iot_host", help="Local/LAN IP or hostname of the IoT device reachable via SSH")
|
ap.add_argument("iot_host", help="Local/LAN IP or hostname of the IoT device reachable via SSH")
|
||||||
ap.add_argument("--name", required=True, help="Device name to use for both hostname and WireGuard client")
|
ap.add_argument("-n", "--name", required=True, help="Device name to use for both hostname and WireGuard client")
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"--steps",
|
"--steps",
|
||||||
nargs="+",
|
nargs="+",
|
||||||
choices=["wg", "hostname", "services", "app", "all"],
|
choices=["wg", "hostname", "update_app", "start_app", "finish", "all"],
|
||||||
default=["all"],
|
default=["all"],
|
||||||
help="Which steps to run. Default: all",
|
help="Which steps to run. Default: all",
|
||||||
)
|
)
|
||||||
# Hostname will be taken from --name
|
# Hostname will be taken from --name
|
||||||
ap.add_argument("--app", choices=["ui", "script"], default="ui", help="Application mode to start")
|
ap.add_argument("--app", choices=["ui", "script"], default="ui", help="Application mode to start")
|
||||||
ap.add_argument("--services", nargs="*", default=[], help="Services to start/enable (logged)")
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
# Validate wg-easy env
|
# Validate wg-easy env
|
||||||
get_env_auth()
|
get_env_auth()
|
||||||
|
|
||||||
|
# Derive device name: if numeric, prefix with 'summitwave-beacon'
|
||||||
|
name = args.name
|
||||||
|
if re.fullmatch(r"\d+", name):
|
||||||
|
name = f"summitwave-beacon{name}"
|
||||||
|
|
||||||
# Normalize steps
|
# Normalize steps
|
||||||
steps = args.steps
|
steps = args.steps
|
||||||
if "all" in steps:
|
if "all" in steps:
|
||||||
steps = ["wg", "hostname", "services", "app"]
|
steps = ["hostname", "wg", "update_app", "start_app", "finish"]
|
||||||
|
# Backward compatibility: map 'app' to 'start_app' if present
|
||||||
|
steps = ["start_app" if s == "app" else s for s in steps]
|
||||||
|
|
||||||
# Gather device facts once (may change after hostname step, but we at least log the initial state)
|
# Gather device facts once (may change after hostname step, but we at least log the initial state)
|
||||||
facts = get_device_facts(args.iot_host)
|
facts = get_device_facts(args.iot_host)
|
||||||
|
|
||||||
# Execute selected steps in order with logging
|
# Execute selected steps in order with logging
|
||||||
if "wg" in steps:
|
|
||||||
wg_info = step_wireguard_provision(args.iot_host, args.name)
|
|
||||||
write_provision_log({
|
|
||||||
"action": "wg",
|
|
||||||
**facts,
|
|
||||||
**wg_info,
|
|
||||||
})
|
|
||||||
if "hostname" in steps:
|
if "hostname" in steps:
|
||||||
host_info = step_set_hostname(args.iot_host, args.name)
|
host_info = step_set_hostname(args.iot_host, name)
|
||||||
# refresh hostname after step (if a real implementation later changes it)
|
# refresh hostname after step (if a real implementation later changes it)
|
||||||
facts_post = get_device_facts(args.iot_host)
|
facts_post = get_device_facts(args.iot_host)
|
||||||
write_provision_log({
|
write_provision_log({
|
||||||
@@ -243,20 +336,34 @@ def main():
|
|||||||
**facts_post,
|
**facts_post,
|
||||||
**host_info,
|
**host_info,
|
||||||
})
|
})
|
||||||
if "services" in steps:
|
if "wg" in steps:
|
||||||
svc_info = step_update_app(args.iot_host, args.services)
|
wg_info = step_wireguard_provision(args.iot_host, name)
|
||||||
write_provision_log({
|
write_provision_log({
|
||||||
"action": "services",
|
"action": "wg",
|
||||||
**get_device_facts(args.iot_host),
|
**facts,
|
||||||
**svc_info,
|
**wg_info,
|
||||||
})
|
})
|
||||||
if "app" in steps:
|
if "update_app" in steps:
|
||||||
|
upd_info = step_update_app(args.iot_host)
|
||||||
|
write_provision_log({
|
||||||
|
"action": "update_app",
|
||||||
|
**get_device_facts(args.iot_host),
|
||||||
|
**upd_info,
|
||||||
|
})
|
||||||
|
if "start_app" in steps:
|
||||||
app_info = step_start_app(args.iot_host, args.app)
|
app_info = step_start_app(args.iot_host, args.app)
|
||||||
write_provision_log({
|
write_provision_log({
|
||||||
"action": "app",
|
"action": "start_app",
|
||||||
**get_device_facts(args.iot_host),
|
**get_device_facts(args.iot_host),
|
||||||
**app_info,
|
**app_info,
|
||||||
})
|
})
|
||||||
|
if "finish" in steps:
|
||||||
|
fin_info = step_finish(args.iot_host)
|
||||||
|
write_provision_log({
|
||||||
|
"action": "finish",
|
||||||
|
**get_device_facts(args.iot_host),
|
||||||
|
**fin_info,
|
||||||
|
})
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user